diff --git a/CHANGELOG.md b/CHANGELOG.md index e9686374..43c3472a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,7 @@ TLDR: The new task state representation is more verbose but significantly cleane - **Breaking**: Remove the `--children` commandline flags, that have been deprecated and no longer serve any function since `v3.0.0`. - Send log output to `stderr` instead of `stdout` [#562](https://github.com/Nukesor/pueue/issues/562). - Change default log level from error to warning [#562](https://github.com/Nukesor/pueue/issues/562). +- Bumped MSRV to 1.70. ### Add @@ -57,12 +58,14 @@ TLDR: The new task state representation is more verbose but significantly cleane - Ability to set the Unix socket permissions through the new `unix_socket_permissions` configuration option. [#544](https://github.com/Nukesor/pueue/pull/544) - Add `command` filter to `pueue status`. [#524](https://github.com/Nukesor/pueue/issues/524) [#560](https://github.com/Nukesor/pueue/pull/560) - Allow `pueue status` to order tasks by `enqueue_at`. [#554](https://github.com/Nukesor/pueue/issues/554) +- Added Windows service on Windows to allow a true daemon experience. [#344](https://github.com/Nukesor/pueue/issues/344) [#567](https://github.com/Nukesor/pueue/pull/567) ### Fixed - Fixed delay after sending process related commands from client. [#548](https://github.com/Nukesor/pueue/pull/548) - Callback templating arguments were html escaped by accident. [#564](https://github.com/Nukesor/pueue/pull/564) - Print incompatible version warning info as a log message instead of plain stdout input, which broke json outputs [#562](https://github.com/Nukesor/pueue/issues/562). +- Fixed `-d` daemon mode on Windows. [#344](https://github.com/Nukesor/pueue/issues/344) ## \[3.4.1\] - 2024-06-04 diff --git a/Cargo.lock b/Cargo.lock index 77fbf73a..0f7f3cd8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -232,15 +232,15 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.7.1" +version = "1.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8318a53db07bb3f8dca91a600466bdb3f2eaadeedfdbcf02e1accbad9271ba50" +checksum = "428d9aa8fbc0670b7b8d6030a7fadd0f86151cae55e4dbbece15f3780a3dfaf3" [[package]] name = "cc" -version = "1.1.20" +version = "1.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45bcde016d64c21da4be18b655631e5ab6d3107607e71a73a9f53eb48aae23fb" +checksum = "07b1695e2c7e8fc85310cde85aeaab7e3097f593c91d209d3f9df76c928100f0" dependencies = [ "shlex", ] @@ -294,9 +294,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.17" +version = "4.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e5a21b8495e732f1b3c364c9949b201ca7bae518c502c80256c96ad79eaf6ac" +checksum = "b0956a43b323ac1afaffc053ed5c4b7c1f1800bacd1683c353aabbb752515dd3" dependencies = [ "clap_builder", "clap_derive", @@ -304,9 +304,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.17" +version = "4.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cf2dd12af7a047ad9d6da2b6b249759a22a7abc0f474c1dae1777afa4b21a73" +checksum = "4d72166dd41634086d5803a47eb71ae740e61d84709c36f3c34110173db3961b" dependencies = [ "anstream", "anstyle", @@ -316,9 +316,9 @@ dependencies = [ [[package]] name = "clap_complete" -version = "4.5.28" +version = "4.5.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b378c786d3bde9442d2c6dd7e6080b2a818db2b96e30d6e7f1b6d224eb617d3" +checksum = "8937760c3f4c60871870b8c3ee5f9b30771f792a7045c48bcbba999d7d6b3b8e" dependencies = [ "clap", ] @@ -335,9 +335,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.13" +version = "4.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "501d359d5f3dcaf6ecdeee48833ae73ec6e42723a1e52419c79abf9507eec0a0" +checksum = "4ac6a0c7b1a9e9a5186361f67dfa1b88213572f427fb9ab038efb2bd8c582dab" dependencies = [ "heck", "proc-macro2", @@ -758,7 +758,7 @@ dependencies = [ "iana-time-zone-haiku", "js-sys", "wasm-bindgen", - "windows-core", + "windows-core 0.52.0", ] [[package]] @@ -893,18 +893,18 @@ checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" [[package]] name = "logos" -version = "0.14.1" +version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff1ceb190eb9bdeecdd8f1ad6a71d6d632a50905948771718741b5461fb01e13" +checksum = "1c6b6e02facda28ca5fb8dbe4b152496ba3b1bd5a4b40bb2b1b2d8ad74e0f39b" dependencies = [ "logos-derive", ] [[package]] name = "logos-codegen" -version = "0.14.1" +version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90be66cb7bd40cb5cc2e9cfaf2d1133b04a3d93b72344267715010a466e0915a" +checksum = "b32eb6b5f26efacd015b000bfc562186472cd9b34bdba3f6b264e2a052676d10" dependencies = [ "beef", "fnv", @@ -917,9 +917,9 @@ dependencies = [ [[package]] name = "logos-derive" -version = "0.14.1" +version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45154231e8e96586b39494029e58f12f8ffcb5ecf80333a603a13aa205ea8cbd" +checksum = "3e5d0c5463c911ef55624739fc353238b4e310f0144be1f875dc42fec6bfd5ec" dependencies = [ "logos-codegen", ] @@ -1095,9 +1095,9 @@ dependencies = [ [[package]] name = "pest" -version = "2.7.12" +version = "2.7.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c73c26c01b8c87956cea613c907c9d6ecffd8d18a2a5908e5de0adfaa185cea" +checksum = "fdbef9d1d47087a895abd220ed25eb4ad973a5e26f6a4367b038c25e28dfc2d9" dependencies = [ "memchr", "thiserror", @@ -1106,9 +1106,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.7.12" +version = "2.7.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "664d22978e2815783adbdd2c588b455b1bd625299ce36b2a99881ac9627e6d8d" +checksum = "4d3a6e3394ec80feb3b6393c725571754c6188490265c61aaf260810d6b95aa0" dependencies = [ "pest", "pest_generator", @@ -1116,9 +1116,9 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.7.12" +version = "2.7.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2d5487022d5d33f4c30d91c22afa240ce2a644e87fe08caad974d4eab6badbe" +checksum = "94429506bde1ca69d1b5601962c73f4172ab4726571a59ea95931218cb0e930e" dependencies = [ "pest", "pest_meta", @@ -1129,9 +1129,9 @@ dependencies = [ [[package]] name = "pest_meta" -version = "2.7.12" +version = "2.7.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0091754bbd0ea592c4deb3a122ce8ecbb0753b738aa82bc055fcc2eccc8d8174" +checksum = "ac8a071862e93690b6e34e9a5fb8e33ff3734473ac0245b27232222c4906a33f" dependencies = [ "once_cell", "pest", @@ -1283,6 +1283,8 @@ dependencies = [ "test-log", "tokio", "whoami", + "windows", + "windows-service", ] [[package]] @@ -2030,9 +2032,9 @@ checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" [[package]] name = "unicode-width" -version = "0.1.13" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0336d538f7abc86d282a4189614dfaa90810dfc2c6f6427eaf88e16311dd225d" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" [[package]] name = "unsafe-libyaml" @@ -2161,6 +2163,12 @@ dependencies = [ "web-sys", ] +[[package]] +name = "widestring" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7219d36b6eac893fa81e84ebe06485e7dcbb616177469b142df14f1f4deb1311" + [[package]] name = "winapi" version = "0.3.9" @@ -2192,6 +2200,16 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.52.0" @@ -2201,6 +2219,71 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-result", + "windows-strings", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-service" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24d6bcc7f734a4091ecf8d7a64c5f7d7066f45585c1861eba06449909609c8a" +dependencies = [ + "bitflags", + "widestring", + "windows-sys 0.52.0", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result", + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.48.0" diff --git a/Cargo.toml b/Cargo.toml index e316f69b..7de0ea0c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ homepage = "https://github.com/nukesor/pueue" repository = "https://github.com/nukesor/pueue" license = "MIT" edition = "2021" -rust-version = "1.67" +rust-version = "1.70" [workspace.dependencies] # Chrono version is hard pinned to a specific version. diff --git a/pueue/Cargo.toml b/pueue/Cargo.toml index 05c00ee9..0cbfbfc4 100644 --- a/pueue/Cargo.toml +++ b/pueue/Cargo.toml @@ -62,6 +62,8 @@ test-log = "0.2" crossterm = { version = "0.27", default-features = false } [target.'cfg(windows)'.dependencies] crossterm = { version = "0.27", default-features = false, features = ["windows"] } +windows-service = "0.7.0" +windows = { version = "0.58.0", features = ["Win32_System_RemoteDesktop", "Win32_Security", "Win32_System_Threading", "Win32_System_SystemServices", "Win32_System_Environment"] } # Test specific dev-dependencies [target.'cfg(any(target_os = "linux", target_os = "freebsd", target_os = "netbsd"))'.dependencies] diff --git a/pueue/src/bin/pueued.rs b/pueue/src/bin/pueued.rs index 6ba11eb7..43d3d8cb 100644 --- a/pueue/src/bin/pueued.rs +++ b/pueue/src/bin/pueued.rs @@ -5,8 +5,7 @@ use clap::Parser; use log::warn; use simplelog::{Config, ConfigBuilder, LevelFilter, SimpleLogger, TermLogger, TerminalMode}; -use pueue::daemon::cli::CliArguments; -use pueue::daemon::run; +use pueue::daemon::{cli::CliArguments, run}; #[tokio::main(flavor = "multi_thread", worker_threads = 4)] async fn main() -> Result<()> { @@ -14,6 +13,15 @@ async fn main() -> Result<()> { let opt = CliArguments::parse(); if opt.daemonize { + // Ordinarily this would be handled in clap, but they don't support conflicting specific args + // with subcommands. We can't turn this off globally because -c and -p are valid args when using + // subcommand to install the service + #[cfg(target_os = "windows")] + if opt.service.is_some() { + println!("daemonize flag cannot be used with service subcommand"); + return Ok(()); + } + return fork_daemon(&opt); } @@ -47,6 +55,44 @@ async fn main() -> Result<()> { SimpleLogger::init(level, logger_config).unwrap(); } + #[cfg(target_os = "windows")] + { + use pueue::daemon::cli::{ServiceSubcommand, ServiceSubcommandEntry}; + use pueue::daemon::service; + + if let Some(ServiceSubcommandEntry::Service(service)) = opt.service { + match service { + ServiceSubcommand::Run => { + // start service + service::run_service(opt.config.clone(), opt.profile.clone())?; + return Ok(()); + } + + ServiceSubcommand::Install => { + service::install_service(opt.config.clone(), opt.profile.clone())?; + println!("Successfully installed `pueued` Windows service"); + return Ok(()); + } + + ServiceSubcommand::Uninstall => { + service::uninstall_service()?; + println!("Successfully uninstalled `pueued` Windows service"); + return Ok(()); + } + + ServiceSubcommand::Start => { + service::start_service()?; + return Ok(()); + } + + ServiceSubcommand::Stop => { + service::stop_service()?; + return Ok(()); + } + } + } + } + run(opt.config, opt.profile, false).await } @@ -78,7 +124,21 @@ fn fork_daemon(opt: &CliArguments) -> Result<()> { "pueued".to_string() }; - Command::new(current_exe) + let mut command = Command::new(current_exe); + + #[cfg(target_os = "windows")] + { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x08000000; + // CREATE_NO_WINDOW causes all children to not show a visible console window, + // but it also apparently has the effect of starting a new process group. + // + // https://learn.microsoft.com/en-us/windows/win32/procthread/process-creation-flags#flags + // https://stackoverflow.com/a/71364777/9423933 + command.creation_flags(CREATE_NO_WINDOW); + } + + command .args(&arguments) .spawn() .expect("Failed to fork new process."); diff --git a/pueue/src/daemon/cli.rs b/pueue/src/daemon/cli.rs index 5a46a5eb..e89450d3 100644 --- a/pueue/src/daemon/cli.rs +++ b/pueue/src/daemon/cli.rs @@ -1,5 +1,7 @@ use std::path::PathBuf; +#[cfg(target_os = "windows")] +use clap::Subcommand; use clap::{ArgAction, Parser, ValueHint}; #[derive(Parser, Debug)] @@ -24,4 +26,36 @@ pub struct CliArguments { /// The name of the profile that should be loaded from your config file. #[arg(short, long)] pub profile: Option, + + #[cfg(target_os = "windows")] + #[command(subcommand)] + pub service: Option, +} + +#[cfg(target_os = "windows")] +#[derive(Copy, Clone, Debug, Subcommand)] +pub enum ServiceSubcommandEntry { + /// Manage the Windows Service. + #[command(subcommand)] + Service(ServiceSubcommand), +} + +#[cfg(target_os = "windows")] +#[derive(Copy, Clone, Debug, Subcommand)] +pub enum ServiceSubcommand { + /// Run the Windows service. This command is internal and should never + /// be used. + Run, + /// Install as a Windows service. + /// Once installed, you must not move the binary, otherwise the Windows + /// service will not be able to find it. If you wish to move the binary, + /// first uninstall the service, move the binary, then install the service + /// again. + Install, + /// Uninstall the service. + Uninstall, + /// Start the service. + Start, + /// Stop the service. + Stop, } diff --git a/pueue/src/daemon/mod.rs b/pueue/src/daemon/mod.rs index 3fd39575..c226f9ca 100644 --- a/pueue/src/daemon/mod.rs +++ b/pueue/src/daemon/mod.rs @@ -23,6 +23,8 @@ pub mod cli; mod network; mod pid; mod process_handler; +#[cfg(target_os = "windows")] +pub mod service; /// Contains re-usable helper functions, that operate on the pueue-lib state. pub mod state_helper; pub mod task_handler; diff --git a/pueue/src/daemon/service.rs b/pueue/src/daemon/service.rs new file mode 100644 index 00000000..86cbcccd --- /dev/null +++ b/pueue/src/daemon/service.rs @@ -0,0 +1,728 @@ +//! How Windows services (and this service) work +//! +//! This service runs as SYSTEM, and survives logoff and logon. On startup/login, it launches +//! pueued as the current user. On logoff Windows kills all user processes (including the daemon). +//! +//! - All install/uninstallations of the service requires you are running as admin. +//! - You must install the service. After installed, the service entry maintains a cmdline +//! string with args to the pueued binary. Therefore, the binary must _not_ move while the +//! service is installed, otherwise it will not be able to function properly. It is best +//! not to rely on PATH for this, as it is finicky and a hassle for user setup. Absolute paths +//! are the way to go, and it is standard practice. +//! - To move the pueued binary: Uninstall the service, move the binary, and reinstall the service. +//! - When the service is installed, you can use pueued cli to start, stop, or uninstall the service. +//! You can also use the official service manager to start, stop, and restart the service. +//! - Services are automatically started/stopped by the system according to the setting the user +//! sets in the windows service manager. By default we install it as autostart, but the user +//! can set this to manual or even disabled. +//! - If you have the official service manager window open and you tell pueued to uninstall the +//! service, it will not disappear from the list until you close all service manager windows. +//! This is not a bug. It's Windows specific behavior. (In Windows parlance, the service is pending +//! deletion, and all HANDLES to the service need to be closed). +//! - We do not support long running daemon past when a user logs off; this would be +//! a massive security risk to allow anyone to launch tasks as SYSTEM. This account bypasses +//! even administrator in power! +//! - Additionally, taking the above into account, as SYSTEM is its own account, the user config +//! does not apply to this account. Unless there's an exception for config locations with this case, +//! you'd have to set up separate configs for the SYSTEM account in +//! `C:\Windows\system32\config\systemprofile\AppData`. (And even if there's an exception, what if +//! there's multiple users? Which user's config would be used?) This is very unintuitive. +//! - Is the service failing to start up? It's probably a problem with the daemon itself. Re-run `pueued` +//! by itself to see the actual startup error. + +use std::{ + env, + ffi::{c_void, OsString}, + iter, + path::PathBuf, + process, ptr, + sync::{ + atomic::{AtomicBool, Ordering}, + mpsc::{channel, Receiver, Sender}, + Arc, Mutex, OnceLock, + }, + thread, + time::Duration, +}; + +use anyhow::{anyhow, bail, Result}; +use log::{debug, error, info}; +use windows::{ + core::{Free, PCWSTR, PWSTR}, + Win32::{ + Foundation::{HANDLE, LUID}, + Security::{ + AdjustTokenPrivileges, DuplicateTokenEx, LookupPrivilegeValueW, SecurityIdentification, + TokenPrimary, SE_PRIVILEGE_ENABLED, SE_PRIVILEGE_REMOVED, SE_TCB_NAME, + TOKEN_ACCESS_MASK, TOKEN_ADJUST_PRIVILEGES, TOKEN_PRIVILEGES, + }, + System::{ + Environment::{CreateEnvironmentBlock, DestroyEnvironmentBlock}, + RemoteDesktop::{WTSGetActiveConsoleSessionId, WTSQueryUserToken}, + SystemServices::MAXIMUM_ALLOWED, + Threading::{ + CreateProcessAsUserW, GetExitCodeProcess, OpenProcess, OpenProcessToken, + TerminateProcess, WaitForSingleObject, CREATE_NO_WINDOW, + CREATE_UNICODE_ENVIRONMENT, INFINITE, PROCESS_INFORMATION, + PROCESS_QUERY_INFORMATION, STARTUPINFOW, + }, + }, + }, +}; +use windows_service::{ + define_windows_service, + service::{ + ServiceAccess, ServiceControl, ServiceControlAccept, ServiceErrorControl, ServiceExitCode, + ServiceInfo, ServiceStartType, ServiceState, ServiceStatus, ServiceType, + SessionChangeParam, SessionChangeReason, SessionNotification, + }, + service_control_handler::{self, ServiceControlHandlerResult}, + service_dispatcher, + service_manager::{ServiceManager, ServiceManagerAccess}, +}; + +#[derive(Clone)] +struct Config { + config_path: Option, + profile: Option, +} + +// The name of the installed service. +const SERVICE_NAME: &str = "pueued"; +// The type of service. This one runs in its own dedicated process. +const SERVICE_TYPE: ServiceType = ServiceType::OWN_PROCESS; +// This static lets us communicate Config over ffi callbacks. +static CONFIG: OnceLock = OnceLock::new(); + +// For how this works, please see docs @ +// https://docs.rs/windows-service/0.7.0/windows_service/#basics +define_windows_service!(ffi_service_main, service_main); + +/// The main service callback after `ffi_service_main`. +fn service_main(_: Vec) { + if let Err(e) = event_loop() { + error!("Failed to start service: {e}"); + } +} + +/// Installs the service. +/// +/// This must be run as admin. +/// +/// This passes the config and profile flags passed at the time of install to the service, e.g. +/// `pueued --config my-path --profile my_profile service install` +/// becomes -> +/// `C:\path\pueued.exe --config "my-path" --profile "my_profile" service run` +/// +/// This is set to run as SYSTEM user, and survives login/logoffs. +pub fn install_service(config_path: Option, profile: Option) -> Result<()> { + let manager_access = ServiceManagerAccess::CONNECT | ServiceManagerAccess::CREATE_SERVICE; + let service_manager = ServiceManager::local_computer(None::<&str>, manager_access)?; + + let service_binary_path = std::env::current_exe()?; + + let mut args = vec![]; + if let Some(config_path) = config_path { + args.extend([ + "--config".into(), + format!(r#""{}""#, config_path.to_string_lossy()).into(), + ]); + } + if let Some(profile) = profile { + args.extend(["--profile".into(), format!(r#""{profile}""#).into()]); + } + + args.extend(["service".into(), "run".into()]); + + let service_info = ServiceInfo { + name: SERVICE_NAME.into(), + display_name: SERVICE_NAME.into(), + service_type: SERVICE_TYPE, + start_type: ServiceStartType::AutoStart, + error_control: ServiceErrorControl::Normal, + executable_path: service_binary_path, + launch_arguments: args, + dependencies: vec![], + account_name: None, // run as System + account_password: None, + }; + + let service = service_manager.create_service(&service_info, ServiceAccess::CHANGE_CONFIG)?; + service.set_description("pueued daemon is a task management tool for sequential and parallel execution of long-running tasks.")?; + + Ok(()) +} + +/// Uninstall the service. +/// +/// This must be run as admin. +pub fn uninstall_service() -> Result<()> { + let manager_access = ServiceManagerAccess::CONNECT; + let service_manager = ServiceManager::local_computer(None::<&str>, manager_access)?; + + let service_access = ServiceAccess::QUERY_STATUS | ServiceAccess::STOP | ServiceAccess::DELETE; + let service = service_manager.open_service(SERVICE_NAME, service_access)?; + + // The service will be marked for deletion as long as this function call succeeds. + // However, it will not be deleted from the database until it is stopped and all open handles to it are closed. + // If the service manager window is open, it will need to be closed before the service gets deleted. + service.delete()?; + + // Our handle to it is not closed yet. So we can still query it. + if service.query_status()?.current_state != ServiceState::Stopped { + // If the service cannot be stopped, it will be deleted when the system restarts. + service.stop()?; + } + + Ok(()) +} + +/// Start the service. +/// +/// This can also be done from the windows service manager. +pub fn start_service() -> Result<()> { + let manager_access = ServiceManagerAccess::CONNECT; + let service_manager = ServiceManager::local_computer(None::<&str>, manager_access)?; + + let service_access = ServiceAccess::QUERY_STATUS | ServiceAccess::START; + let service = service_manager.open_service(SERVICE_NAME, service_access)?; + + match service.query_status()?.current_state { + ServiceState::Stopped => { + service.start::(&[])?; + println!("Successfully started service"); + } + ServiceState::StartPending => println!("Service is already starting"), + ServiceState::Running => println!("Service is already running"), + + _ => (), + } + + Ok(()) +} + +/// Stop the service. +/// +/// This can also be done from the windows service manager. +pub fn stop_service() -> Result<()> { + let manager_access = ServiceManagerAccess::CONNECT; + let service_manager = ServiceManager::local_computer(None::<&str>, manager_access)?; + + let service_access = ServiceAccess::QUERY_STATUS | ServiceAccess::STOP; + let service = service_manager.open_service(SERVICE_NAME, service_access)?; + + match service.query_status()?.current_state { + ServiceState::Stopped => println!("Service is already stopped"), + ServiceState::StartPending => println!("Service cannot stop because it is starting (please wait until it fully started to stop it)"), + ServiceState::Running => { + service.stop()?; + println!("Successfully stopped service"); + } + + _ => (), + } + + Ok(()) +} + +/// Begins running the pueued service. +/// +/// This calls `ffi_service_main` -> `service_main` -> `event_loop` +pub fn run_service(config_path: Option, profile: Option) -> Result<()> { + CONFIG + .set(Config { + config_path, + profile, + }) + .map_err(|_| anyhow!("static CONFIG set failed"))?; + + service_dispatcher::start(SERVICE_NAME, ffi_service_main)?; + Ok(()) +} + +/// This is the main event loop for the service. +/// +/// This gets called from `run_service` -> `ffi_service_main` -> `service_main` -> `event_loop` +fn event_loop() -> Result<()> { + let spawner = Arc::new(Spawner::new()); + // Whether a shutdown of the service was requested. + let shutdown = Arc::new(AtomicBool::default()); + + // The main event handler for the service. + let event_handler = { + let spawner = spawner.clone(); + let shutdown = shutdown.clone(); + + move |control_event| -> ServiceControlHandlerResult { + match control_event { + // Stop + ServiceControl::Stop => { + debug!("event stop"); + // Important! Set the while loop's exit condition before calling stop(), otherwise + // the condition will not be observed. + shutdown.store(true, Ordering::Relaxed); + spawner.stop(); + + ServiceControlHandlerResult::NoError + } + + // Logon + ServiceControl::SessionChange(SessionChangeParam { + reason: SessionChangeReason::SessionLogon, + notification: + SessionNotification { + session_id: session, + .. + }, + }) => { + debug!("event login"); + if !spawner.running() { + debug!("event login: spawning"); + if let Err(e) = spawner.start(session) { + error!("failed to spawn: {e}"); + return ServiceControlHandlerResult::Other(1); + } + } + + ServiceControlHandlerResult::NoError + } + + // Logoff + ServiceControl::SessionChange(SessionChangeParam { + reason: SessionChangeReason::SessionLogoff, + .. + }) => { + // Windows kills all user processes on logoff. + // So we don't actually need to stop any running spawner. + debug!("event logoff"); + + ServiceControlHandlerResult::NoError + } + + // Other session change events we don't care about. + ServiceControl::SessionChange(_) => ServiceControlHandlerResult::NoError, + + // All services must accept Interrogate even if it's a no-op. + ServiceControl::Interrogate => ServiceControlHandlerResult::NoError, + + // Nothing else is implemented. + _ => ServiceControlHandlerResult::NotImplemented, + } + } + }; + + // Register system service event handler + let status_handle = service_control_handler::register(SERVICE_NAME, event_handler)?; + + let set_status = move |state: ServiceState, controls: ServiceControlAccept| -> Result<()> { + status_handle.set_service_status(ServiceStatus { + service_type: SERVICE_TYPE, + current_state: state, + controls_accepted: controls, + exit_code: ServiceExitCode::Win32(0), + checkpoint: 0, + wait_hint: Duration::default(), + process_id: None, + })?; + + Ok(()) + }; + + set_status(ServiceState::StartPending, ServiceControlAccept::empty())?; + + // Make sure we have privileges - this should always succeed + if let Err(e) = set_privilege(SE_TCB_NAME, true) { + set_status(ServiceState::Stopped, ServiceControlAccept::empty())?; + bail!("failed to set privileges: {e}"); + } + + // This attempt is required in order to properly start pueued if the user starts/restarts + // the service manually, since no events would have been triggered from that. + // + // If we can get the current user session on startup, then try to start the spawner. + // + // If we can't get the current session, that's OK. It just means there was no user logged in when + // the service started. + // + // The event handler will start it when the user logs in. + if let Some(session) = get_current_session() { + if let Err(e) = spawner.start(session) { + error!("failed to spawn: {e}"); + } + } + + set_status( + ServiceState::Running, + ServiceControlAccept::STOP | ServiceControlAccept::SESSION_CHANGE, + )?; + + // While there's no shutdown request, and the spawner didn't exit unexpectedly, + // keep the service running. + while !shutdown.load(Ordering::Relaxed) && !spawner.dirty() { + debug!("spawner wait()"); + spawner.wait(); + } + + info!("shutting down service"); + + set_status(ServiceState::Stopped, ServiceControlAccept::empty())?; + + Ok(()) +} + +/// Set the specified process privilege to state. +/// https://learn.microsoft.com/en-us/windows/win32/secauthz/privilege-constants +fn set_privilege(name: PCWSTR, state: bool) -> Result<()> { + let handle: OwnedHandle = + unsafe { OpenProcess(PROCESS_QUERY_INFORMATION, false, process::id())?.into() }; + + let mut token: OwnedHandle = OwnedHandle::default(); + unsafe { + OpenProcessToken(handle.0, TOKEN_ADJUST_PRIVILEGES, &mut token.0)?; + } + + let mut luid = LUID::default(); + + unsafe { + LookupPrivilegeValueW(PCWSTR::null(), name, &mut luid)?; + } + + let mut tp = TOKEN_PRIVILEGES { + PrivilegeCount: 1, + ..Default::default() + }; + + tp.Privileges[0].Luid = luid; + + let attributes = if state { + SE_PRIVILEGE_ENABLED + } else { + SE_PRIVILEGE_REMOVED + }; + + tp.Privileges[0].Attributes = attributes; + + unsafe { + AdjustTokenPrivileges(token.0, false, Some(&tp), 0, None, None)?; + } + + Ok(()) +} + +/// Get the current user session. Only needed when we don't initially have a session id to go by. +fn get_current_session() -> Option { + let session = unsafe { WTSGetActiveConsoleSessionId() }; + + match session { + // No session attached. + // https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-wtsgetactiveconsolesessionid#return-value + 0xFFFFFFFF => None, + // Found a session! + session => Some(session), + } +} + +/// Run closure and supply the currently logged in user's token. +fn run_as(session_id: u32, cb: impl FnOnce(HANDLE) -> Result) -> Result { + let mut query_token: OwnedHandle = OwnedHandle::default(); + // Obtain the user's primary access token. Requires we are SYSTEM and have SE_TCB_NAME. + // + // Make sure to not leak this token anywhere, as it must remain secure. + // https://learn.microsoft.com/en-us/windows/win32/api/wtsapi32/nf-wtsapi32-wtsqueryusertoken + unsafe { + WTSQueryUserToken(session_id, &mut query_token.0)?; + } + + let mut token = OwnedHandle::default(); + + unsafe { + DuplicateTokenEx( + query_token.0, + TOKEN_ACCESS_MASK(MAXIMUM_ALLOWED), + None, + SecurityIdentification, + TokenPrimary, + &mut token.0, + )?; + } + + drop(query_token); + + cb(token.0) +} + +/// Newtype over handle which closes the HANDLE on drop. +#[derive(Default)] +struct OwnedHandle(HANDLE); + +unsafe impl Send for OwnedHandle {} +unsafe impl Sync for OwnedHandle {} + +impl OwnedHandle { + fn is_valid(&self) -> bool { + !self.0.is_invalid() + } +} + +impl From for OwnedHandle { + fn from(value: HANDLE) -> Self { + Self(value) + } +} + +impl Drop for OwnedHandle { + fn drop(&mut self) { + unsafe { + self.0.free(); + } + } +} + +/// A child process. Tries to kill the process when dropped. +struct Child(OwnedHandle); + +impl Child { + fn new() -> Self { + Self(OwnedHandle::default()) + } + + fn kill(&mut self) -> Result<()> { + if self.0.is_valid() { + unsafe { + TerminateProcess(self.0 .0, 0)?; + } + + self.0 = OwnedHandle::default(); + } + + Ok(()) + } + + fn reset(&mut self) { + self.0 = OwnedHandle::default(); + } +} + +impl Default for Child { + fn default() -> Self { + Self::new() + } +} + +impl Drop for Child { + fn drop(&mut self) { + _ = self.kill(); + } +} + +/// A users' environment block. +/// https://learn.microsoft.com/en-us/windows/win32/api/userenv/nf-userenv-createenvironmentblock +struct EnvBlock(*mut c_void); + +impl EnvBlock { + /// get the environment block belonging to the supplied users token + fn new(token: HANDLE) -> Result { + let mut env = ptr::null_mut(); + unsafe { + CreateEnvironmentBlock(&mut env, token, false)?; + } + + Ok(Self(env)) + } +} + +impl Drop for EnvBlock { + fn drop(&mut self) { + _ = unsafe { DestroyEnvironmentBlock(self.0) }; + } +} + +/// Manages the child daemon, by spawning / stopping it, or reporting abnormal exit, and allowing wait(). +struct Spawner { + // Whether a child daemon is running. + running: Arc, + // Holds the actual process of the running child daemon. + child: Arc>, + // Whether the process has exited without our request. + dirty: Arc, + // Used to differentiate between requested stop() and if process is dirty (see above). + request_stop: Arc, + // Used for wait()ing until the child is done. + wait_tx: Sender<()>, + // We don't need mutation, but we do need Sync. + wait_rx: Mutex>, +} + +impl Spawner { + fn new() -> Self { + let (wait_tx, wait_rx) = channel(); + + Self { + running: Arc::default(), + child: Arc::default(), + dirty: Arc::default(), + request_stop: Arc::default(), + wait_tx, + wait_rx: Mutex::new(wait_rx), + } + } + + /// Is the child daemon running? + fn running(&self) -> bool { + self.running.load(Ordering::Relaxed) + } + + /// Stop the spawned daemon. + /// + /// Note: if you need any `while` loop to exit by checking a condition, + /// make _sure_ you put this stop() _after_ you change the `while` condition to false + /// otherwise any condition change will not be observed. + fn stop(&self) { + let mut child = self.child.lock().unwrap(); + + // Request a normal stop. This is not an abnormal process exit. + self.request_stop.store(true, Ordering::Relaxed); + match child.kill() { + Ok(_) => { + debug!("stop() kill"); + self.running.store(false, Ordering::Relaxed); + // Signal the wait() to exit so a `while` condition is checked at least once more. + // As long as `while` conditions have been changed _before_ the call to stop(), + // the changed condition will be observed. + _ = self.wait_tx.send(()); + } + + Err(e) => { + self.running.store(false, Ordering::Relaxed); + error!("failed to stop(): {e}"); + } + } + } + + /// Wait for child process to exit. + fn wait(&self) { + _ = self.wait_rx.lock().unwrap().recv(); + } + + /// Did the spawned process quit without our request? + fn dirty(&self) -> bool { + self.dirty.load(Ordering::Relaxed) + } + + /// Try to spawn a child daemon. + fn start(&self, session: u32) -> Result<()> { + let running = self.running.clone(); + let child = self.child.clone(); + let waiter = self.wait_tx.clone(); + let dirty = self.dirty.clone(); + let request_stop = self.request_stop.clone(); + _ = thread::spawn(move || { + request_stop.store(false, Ordering::Relaxed); + + let res = run_as(session, move |token| { + let mut arguments = Vec::new(); + + let config = CONFIG + .get() + .ok_or_else(|| anyhow!("failed to get CONFIG"))? + .clone(); + + if let Some(config) = &config.config_path { + arguments.push("--config".to_string()); + arguments.push(format!(r#""{}""#, config.to_string_lossy().into_owned())); + } + + if let Some(profile) = &config.profile { + arguments.push("--profile".to_string()); + arguments.push(format!(r#""{profile}""#)); + } + + let arguments = arguments.join(" "); + + // Try to get the path to the current binary + let mut current_exe = env::current_exe()? + .to_string_lossy() + .to_string() + .encode_utf16() + .chain(iter::once(0)) + .collect::>(); + + let mut arguments = arguments + .encode_utf16() + .chain(iter::once(0)) + .collect::>(); + + let env_block = EnvBlock::new(token)?; + + let mut process_info = PROCESS_INFORMATION::default(); + unsafe { + CreateProcessAsUserW( + token, + PWSTR(current_exe.as_mut_ptr()), + PWSTR(arguments.as_mut_ptr()), + None, + None, + false, + // CREATE_UNICODE_ENVIRONMENT is required if we pass env block. + // https://learn.microsoft.com/en-us/windows/win32/api/userenv/nf-userenv-createenvironmentblock#remarks + // + // CREATE_NO_WINDOW causes all child processes to not show a visible console window. + // https://stackoverflow.com/a/71364777/9423933 + CREATE_UNICODE_ENVIRONMENT | CREATE_NO_WINDOW, + Some(env_block.0), + None, + &STARTUPINFOW::default(), + &mut process_info, + )?; + } + + // It is safe to drop this after calling CreateProcessAsUser. + // https://learn.microsoft.com/en-us/windows/win32/api/userenv/nf-userenv-createenvironmentblock#remarks + drop(env_block); + + // Store the child process. + { + let mut lock = child.lock().unwrap(); + *lock = Child(process_info.hProcess.into()); + } + + running.store(true, Ordering::Relaxed); + + // Wait until the process exits. + unsafe { + WaitForSingleObject(process_info.hProcess, INFINITE); + } + + running.store(false, Ordering::Relaxed); + + // Check if process exited on its own without our explicit request (`stop()` was not called). + if !request_stop.swap(false, Ordering::Relaxed) { + let mut code = 0u32; + unsafe { + GetExitCodeProcess(process_info.hProcess, &mut code)?; + } + + debug!("spawner code {code}"); + + // Windows gives exit code 0x40010004 when it did a forced process shutdown. + // This happens on logoff, so we ignore this code as it's not dirty. + if code != 0x40010004 { + debug!("service storing dirty true"); + dirty.store(true, Ordering::Relaxed); + _ = waiter.send(()); + } + } + + child.lock().unwrap().reset(); + + Ok(()) + }); + + if let Err(e) = res { + error!("spawner failed: {e}"); + } + }); + + Ok(()) + } +}