diff --git a/Cargo.lock b/Cargo.lock index 79da5e12..c5901013 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4519,6 +4519,7 @@ dependencies = [ "tracing-appender", "tracing-subscriber", "uuid", + "windows-service", "windows-sys 0.61.2", "zip 9.0.5", ] @@ -7830,6 +7831,12 @@ dependencies = [ "safe_arch", ] +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + [[package]] name = "winapi" version = "0.3.9" @@ -7980,6 +7987,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-service" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "857224b3b211c6f3616921f081ee54721ee3ad2ace2fac6a6337e032f7b4dcf2" +dependencies = [ + "bitflags 2.13.2", + "widestring", + "windows-sys 0.61.2", +] + [[package]] name = "windows-strings" version = "0.5.1" diff --git a/Cargo.toml b/Cargo.toml index ab4ea367..d66c4ac7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -117,6 +117,9 @@ schemars = "1" # macOS memory probing, and signalling the process group a WBPP run starts. libc = "0.2" [target.'cfg(target_os = "windows")'.dependencies] +# The worker as a Windows service: a thin wrapper of the Service Control +# Manager API. +windows-service = "0.8" windows-sys = { version = "0.61", features = [ "Win32_Foundation", "Win32_Storage_FileSystem", diff --git a/docs/REMOTE_WORKERS.md b/docs/REMOTE_WORKERS.md index 1fd53521..a19b1f92 100644 --- a/docs/REMOTE_WORKERS.md +++ b/docs/REMOTE_WORKERS.md @@ -162,6 +162,84 @@ again every 30 seconds; `journalctl -u psf-guard-worker` shows why. `PSF_GUARD_WORKER_EXTRA_ARGS` is split on spaces, so it cannot hold a path that contains one. +## Run a worker as a Windows service + +Use a Windows service when the worker needs to run before anyone logs in. +The service starts with Windows. It uses its own copies of the app and +settings, separate from the desktop worker. + +1. Open **Settings › Workers › Worker on this computer** in the desktop + app and enter the worker settings. Turn off **Start worker at login**, + then click **Save worker settings**. If the desktop worker is running, + click **Stop worker** before installing the service. +2. Open PowerShell as administrator and run: + + ```powershell + psf-guard-cli worker-service install + ``` + + To use a different settings file, add `--config FILE`. + +The command installs and starts the **PSF Guard Worker** service. It copies +the executable to `%ProgramFiles%\PSF Guard Worker` and settings to +`%ProgramData%\PSF Guard Worker\worker.json`. SYSTEM, Administrators and +the service account can access the service's data folder. The settings +include the worker token. + +The service runs under its own account, `NT SERVICE\PSFGuardWorker`. +Task files use `%ProgramData%\PSF Guard Worker\tasks`; the desktop app's +**Temporary task folder** setting does not change the service's task folder. + +### Apply settings or app updates to the service + +After changing worker settings, click **Save worker settings**. After +saving settings or updating PSF Guard, open PowerShell as administrator and +run `psf-guard-cli worker-service install` again. The command stops the +service, updates its copies of the app and settings, and restarts it. + +**Restart worker** controls the desktop worker. It does not restart or +update the Windows service. While the service is running or changing state, +**Start worker** is disabled to prevent a second worker from starting. + +### Check service logs and requirements + +- **Logs:** filenames start with `worker` in + `%ProgramData%\PSF Guard Worker\logs`. +- **Access denied:** if the service cannot read `worker.json`, update PSF Guard + and run `psf-guard-cli worker-service install` from PowerShell opened as + administrator. This repairs the service folder's permissions and starts + the worker. +- **Failures:** if the server refuses the token, the service logs the reason + and stops. Windows restarts it after 30 seconds. Replace a refused token + in the desktop settings and run the install command again. +- **Stopping:** stop the service in Windows Services. The worker disconnects + and the server queues unfinished tasks again. Windows shutdown also gives + the worker time to disconnect. +- **Sleep:** prevent the computer from sleeping while you need it to process + tasks. A worker cannot process tasks while Windows is asleep. +- **RC-Astro tools:** the service uses rc-astro from its account's `PATH`, + or the executable specified in its settings. An installation or license + available only to your desktop account may be unavailable to the service. + If the service cannot run RC-Astro, it can still accept stack builds. + Use the desktop worker if RC-Astro requires your desktop account. +- **Shared storage:** the default service account accesses network shares + through the computer's network identity. If a share requires a user + account, change the service account in Windows Services to a user with + access to that share. Give that user modify access to + `%ProgramData%\PSF Guard Worker`. Enter folder mappings with UNC paths, + such as `\\nas\astro`, that the service account can access. + Reinstalling restores the default service account. After reinstalling, + stop the service, reapply your custom account, and start the service in + Windows Services. + +### Remove the Windows service + +Before uninstalling PSF Guard, open PowerShell as administrator and run +`psf-guard-cli worker-service uninstall`. This stops and removes the service +and deletes its copied executable, settings including the token, task files, +and logs. The server queues unfinished tasks again. The desktop app's +saved worker settings remain in its own settings folder. + ## Choose the work a worker accepts A worker accepts everything it can run unless you list its work with diff --git a/docs/design/remote-workers.md b/docs/design/remote-workers.md index cba2e25c..a8c2456e 100644 --- a/docs/design/remote-workers.md +++ b/docs/design/remote-workers.md @@ -219,6 +219,27 @@ and registers no plugins. - **Desktop app.** `src/desktop_worker.rs` has the commands behind **Settings › Workers › Worker on this computer**. They read and save settings, start the tray process, and pause, resume and stop it through the control port. +- **Windows service** (`commands/worker/service.rs`). Service startup uses + copies that unprivileged processes cannot modify. `worker-service install` + copies the executable to `%ProgramFiles%\PSF Guard Worker\psf-guard-worker.exe` + and settings to `%ProgramData%\PSF Guard Worker\worker.json`. It sets + `work_dir` to the service's own `tasks` folder. The service runs as the + virtual account `NT SERVICE\PSFGuardWorker`. + `icacls` restricts the data folder to SYSTEM, Administrators and that + account. Settings and task files inherit the folder's permissions; + reinstalling repairs permissions left by earlier installs. + Running install again stops the service, updates its copies and + configuration, and restarts it. Installation and removal wait for the + stopped service's process to exit before replacing or deleting its + executable. Startup is automatic and delayed. During preshutdown, Windows + allows up to 60 seconds for the worker to disconnect; the service reports + `StopPending` with an increasing checkpoint every five seconds. + A failed run logs its reason and exits with a service-specific code; + Windows restarts it after 30 seconds. `worker-service uninstall` stops + and removes the service, then deletes its executable, settings, task files + and logs. The desktop app disables **Start worker** until the service + stops. A Windows service state does not prove a connection to the + processing server, so the UI reports it separately. - **Start worker at login** (`commands/worker/login.rs`): a LaunchAgent with `RunAtLoad` on macOS, and a value under `HKCU\Software\Microsoft\Windows\CurrentVersion\Run` on Windows. Both diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index 7f6a4b38..d89634df 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -19,6 +19,11 @@ - FITS downloads in stack processing use compact controls with a download icon and explicit labels. Long labels wrap to fit narrow panels. +- Windows worker services can now read their saved settings and start + successfully. Reinstalling the service also repairs permissions left by an + earlier installation and waits for the stopped worker process before + replacing its executable. + - A fresh flat that contributes to a light's master now provides coverage even when older flats in the same master move the median capture time beyond the **Flat age limit**. Age checks and the calibration report use the nearest @@ -38,6 +43,13 @@ you log in. Opening PSF Guard while its window is open brings that window forward instead of starting a second copy. +- Run a worker as a Windows service to process tasks before anyone logs in. + Open **Settings › Workers › Worker on this computer**, click **Save worker + settings**, then run `psf-guard-cli worker-service install` in PowerShell + opened as administrator. Run the command again after changing settings or + updating PSF Guard to update and restart the service. Desktop worker + controls do not control the service. + - Run a worker as a Linux service. The Fedora RPM and the Debian package install a `psf-guard-worker` systemd service, not enabled. Enter the server's address and a worker token in `/etc/psf-guard/worker.conf`, then diff --git a/src/cli.rs b/src/cli.rs index 77f10ea9..48d74195 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -14,7 +14,27 @@ pub struct Cli { pub command: Commands, } -/// CLI spelling of [`crate::server::stack_preview::snr::StackFrameOrder`]. +/// What `psf-guard worker-service` does. +#[derive(Subcommand, Debug)] +pub enum WorkerServiceAction { + /// Install the service, and start it. + Install { + /// The worker's settings file (defaults to the one the desktop app + /// saves for this user). + #[arg(long, value_name = "FILE")] + config: Option, + }, + /// Stop and remove the service. Delete its copied executable, settings, + /// task files and logs. + Uninstall, + /// Run as the service. Windows starts this; it is not for a terminal. + #[command(hide = true)] + Run { + #[arg(long, value_name = "FILE")] + config: String, + }, +} + /// How a worker reaches task files. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, ValueEnum)] pub enum WorkerFilesArg { @@ -25,6 +45,7 @@ pub enum WorkerFilesArg { Shared, } +/// CLI spelling of [`crate::server::stack_preview::snr::StackFrameOrder`]. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, ValueEnum)] pub enum StackOrderArg { /// Chronological. @@ -1388,6 +1409,18 @@ pub enum Commands { slots: usize, }, + /// Run the worker as a Windows service, for a computer that runs work + /// while nobody is logged in. + /// + /// Save the worker's settings in the desktop app's Settings › Workers › + /// This computer first, or write a settings file. Then, from a terminal opened + /// as administrator, run `psf-guard-cli worker-service install`. The + /// service runs as Local System and starts with Windows. + WorkerService { + #[command(subcommand)] + action: WorkerServiceAction, + }, + /// Start the web server for API access and static file serving Server { /// Path to TOML configuration file diff --git a/src/cli_main.rs b/src/cli_main.rs index f1abd13b..4d3c8f5c 100644 --- a/src/cli_main.rs +++ b/src/cli_main.rs @@ -1639,6 +1639,29 @@ pub fn main() -> Result<()> { ))?; } } + Commands::WorkerService { action } => { + use crate::cli::WorkerServiceAction; + use crate::commands::worker::{service, WorkerSettings}; + match action { + WorkerServiceAction::Install { config } => { + let path = match config { + Some(config) => std::path::PathBuf::from(config), + None => WorkerSettings::default_path()?, + }; + service::install(&path)?; + println!( + "The PSF Guard Worker service runs with a copy of the settings in {}. \ + After you change them, or update PSF Guard, run this command again.", + path.display() + ); + } + WorkerServiceAction::Uninstall => { + service::uninstall()?; + println!("Stopped and removed the PSF Guard Worker service"); + } + WorkerServiceAction::Run { config } => service::run(config.into())?, + } + } Commands::Users { action } => { crate::commands::users::manage_users(action)?; } diff --git a/src/commands/worker.rs b/src/commands/worker.rs index 7aead73c..081cba6c 100644 --- a/src/commands/worker.rs +++ b/src/commands/worker.rs @@ -27,6 +27,7 @@ use crate::remote_work::{file_digest, map_path, LocalTools, WorkChoice}; pub mod control; pub mod login; +pub mod service; pub mod settings; pub use control::{WorkerControl, WorkerStatus}; diff --git a/src/commands/worker/service.rs b/src/commands/worker/service.rs new file mode 100644 index 00000000..229af35f --- /dev/null +++ b/src/commands/worker/service.rs @@ -0,0 +1,510 @@ +//! `psf-guard worker-service`: the worker as a Windows service, for a +//! computer that runs work while nobody is logged in. +//! +//! The app installs per user, in a folder its user can change, so the +//! service never runs the app's own files. `install` copies the binary to +//! `%ProgramFiles%\PSF Guard Worker` and the settings to +//! `%ProgramData%\PSF Guard Worker`, which only administrators and the +//! service's account can reach, and runs the service as its own virtual +//! account, `NT SERVICE\PSFGuardWorker`, rather than Local System. Running +//! `install` again updates the copies, such as after the app is updated. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; + +#[cfg(windows)] +mod permissions; +#[cfg(windows)] +mod process; + +/// The service's name, as `sc.exe` and the Services console identify it. +#[cfg_attr(not(windows), allow(dead_code))] +const SERVICE_NAME: &str = "PSFGuardWorker"; +#[cfg_attr(not(windows), allow(dead_code))] +const DISPLAY_NAME: &str = "PSF Guard Worker"; +#[cfg_attr(not(windows), allow(dead_code))] +const DESCRIPTION: &str = "Runs stack builds and RC-Astro tools for a PSF Guard server."; +/// The folder under Program Files for the binary, and under ProgramData for +/// the settings, the task files and the logs. +#[cfg_attr(not(windows), allow(dead_code))] +const FOLDER: &str = "PSF Guard Worker"; + +/// Install the service with the settings at `settings`, or update it, and +/// start it. +pub fn install(settings: &Path) -> Result<()> { + let settings = + std::path::absolute(settings).with_context(|| format!("finding {}", settings.display()))?; + let loaded = super::WorkerSettings::load(&settings)?.ok_or_else(|| { + anyhow::anyhow!( + "No worker settings file was found at {}. Save worker settings in PSF Guard's \ + Settings › Workers › Worker on this computer, or choose a file with --config FILE", + settings.display() + ) + })?; + loaded.validate()?; + platform::install(loaded) +} + +/// Stop the service and remove it, with its copies of the binary and the +/// settings. +pub fn uninstall() -> Result<()> { + platform::uninstall() +} + +/// Run as the service. Windows starts this; it is not for a terminal. +pub fn run(settings: PathBuf) -> Result<()> { + platform::run(settings) +} + +/// Whether the service is installed, and whether it runs: for the desktop +/// app, which shows a service worker in place of its own. +pub fn state() -> Option { + platform::state() +} + +/// What the desktop app shows about the service. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ServiceState { + Running, + Stopped, + /// Starting, stopping, paused or resuming. + Changing, +} + +/// The settings the service runs with: the saved ones, with the service's +/// own task folder. +#[cfg_attr(not(windows), allow(dead_code))] +fn service_settings(mut settings: super::WorkerSettings, tasks: &Path) -> super::WorkerSettings { + settings.work_dir = Some(tasks.display().to_string()); + settings +} + +/// The arguments the service starts the binary with. +#[cfg_attr(not(windows), allow(dead_code))] +fn launch_arguments(settings: &Path) -> Vec { + vec![ + "worker-service".into(), + "run".into(), + "--config".into(), + settings.as_os_str().to_owned(), + ] +} + +#[cfg(windows)] +mod platform { + use std::ffi::OsString; + use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; + use std::sync::{Arc, OnceLock}; + use std::time::Duration; + + use windows_service::service::{ + Service, ServiceAccess, ServiceAction, ServiceActionType, ServiceControl, + ServiceControlAccept, ServiceErrorControl, ServiceExitCode, ServiceFailureActions, + ServiceFailureResetPeriod, ServiceInfo, ServiceStartType, ServiceState as State, + ServiceStatus, ServiceType, + }; + use windows_service::service_control_handler::{ + self, ServiceControlHandlerResult, ServiceStatusHandle, + }; + use windows_service::service_manager::{ServiceManager, ServiceManagerAccess}; + use windows_service::{define_windows_service, service_dispatcher}; + + use super::*; + use crate::commands::worker::{run_with, WorkerControl, WorkerLaunch}; + + /// How long the worker may take to stop: its tool stops within 30 + /// seconds, and it then leaves the server. + const STOP_WAIT: Duration = Duration::from_secs(60); + /// How long after a failure Windows starts the service again. + const RESTART_AFTER: Duration = Duration::from_secs(30); + /// How often a stop reports that it is still going. + const STOP_PROGRESS: Duration = Duration::from_secs(5); + + const ERROR_ACCESS_DENIED: i32 = 5; + const ERROR_SERVICE_DOES_NOT_EXIST: i32 = 1060; + const REFUSED: &str = "Administrator access is required to manage the PSF Guard Worker service. Open PowerShell as administrator and run the command again."; + + static SETTINGS: OnceLock = OnceLock::new(); + + define_windows_service!(ffi_service_main, service_main); + + /// Where the service's binary goes: only administrators can change it. + fn binary() -> Result { + let folder = std::env::var_os("ProgramFiles") + .map(PathBuf::from) + .context("Windows did not report the Program Files folder")?; + Ok(folder.join(FOLDER).join("psf-guard-worker.exe")) + } + + /// The service's data folder: its settings, task files and logs. + fn data() -> PathBuf { + std::env::var_os("ProgramData") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(r"C:\ProgramData")) + .join(FOLDER) + } + + fn os_error(error: &windows_service::Error) -> Option { + match error { + windows_service::Error::Winapi(error) => error.raw_os_error(), + _ => None, + } + } + + fn manager(access: ServiceManagerAccess) -> Result { + ServiceManager::local_computer(None::<&str>, access).map_err(|error| { + if os_error(&error) == Some(ERROR_ACCESS_DENIED) { + anyhow::anyhow!(REFUSED) + } else { + anyhow::Error::new(error).context("opening the Windows services") + } + }) + } + + /// The installed service, or `None` when there is none. + fn open(manager: &ServiceManager, access: ServiceAccess) -> Result> { + match manager.open_service(SERVICE_NAME, access) { + Ok(service) => Ok(Some(service)), + Err(error) if os_error(&error) == Some(ERROR_SERVICE_DOES_NOT_EXIST) => Ok(None), + Err(error) if os_error(&error) == Some(ERROR_ACCESS_DENIED) => { + Err(anyhow::anyhow!(REFUSED)) + } + Err(error) => { + Err(anyhow::Error::new(error).context("opening the PSF Guard Worker service")) + } + } + } + + /// Stop the service and wait until its process has released the binary. + fn stop_and_wait(service: &Service) -> Result<()> { + let status = service.query_status()?; + if status.current_state == State::Stopped { + return Ok(()); + } + // Keep a process handle before requesting Stop: the SCM no longer + // reports a PID once the worker publishes its Stopped status, even + // though the process can still be releasing its executable and logs. + let process = status + .process_id + .map(process::Process::open) + .transpose()? + .flatten(); + if status.current_state != State::StopPending { + service.stop()?; + } + let deadline = std::time::Instant::now() + STOP_WAIT + Duration::from_secs(10); + while service.query_status()?.current_state != State::Stopped { + anyhow::ensure!( + std::time::Instant::now() < deadline, + "The PSF Guard Worker service is still stopping; try again in a minute" + ); + std::thread::sleep(Duration::from_millis(250)); + } + if let Some(process) = process { + process.wait(deadline.saturating_duration_since(std::time::Instant::now()))?; + } + Ok(()) + } + + pub fn install(settings: crate::commands::worker::WorkerSettings) -> Result<()> { + let manager = + manager(ServiceManagerAccess::CONNECT | ServiceManagerAccess::CREATE_SERVICE)?; + let access = ServiceAccess::CHANGE_CONFIG + | ServiceAccess::START + | ServiceAccess::STOP + | ServiceAccess::QUERY_STATUS; + let existing = open(&manager, access)?; + // The copies can change only while the service is stopped. + if let Some(service) = &existing { + stop_and_wait(service)?; + } + + let binary = binary()?; + let data = data(); + let tasks = data.join("tasks"); + let saved = data.join("worker.json"); + std::fs::create_dir_all(binary.parent().expect("a Program Files folder"))?; + std::fs::create_dir_all(&tasks)?; + std::fs::create_dir_all(data.join("logs"))?; + std::fs::copy(std::env::current_exe()?, &binary) + .with_context(|| format!("copying PSF Guard to {}", binary.display()))?; + service_settings(settings, &tasks).save(&saved)?; + + let info = ServiceInfo { + name: OsString::from(SERVICE_NAME), + display_name: OsString::from(DISPLAY_NAME), + service_type: ServiceType::OWN_PROCESS, + start_type: ServiceStartType::AutoStart, + error_control: ServiceErrorControl::Normal, + executable_path: binary, + launch_arguments: launch_arguments(&saved), + dependencies: Vec::new(), + // A virtual account of the service's own, which Windows creates + // with it and which needs no password. + account_name: Some(OsString::from(format!(r"NT SERVICE\{SERVICE_NAME}"))), + account_password: None, + }; + let service = match existing { + Some(service) => { + service.change_config(&info)?; + service + } + None => manager + .create_service(&info, access) + .context("The service could not be created")?, + }; + // Only now does the service's account exist to be granted access. + permissions::lock_down(&data, &format!(r"NT SERVICE\{SERVICE_NAME}"))?; + service.set_description(DESCRIPTION)?; + service.set_delayed_auto_start(true)?; + // A restart or a shutdown gives the worker time to leave the server. + service.set_preshutdown_timeout(STOP_WAIT)?; + // After a failure, such as a refused token, start again in 30 + // seconds, as the Linux service does. + service.update_failure_actions(ServiceFailureActions { + reset_period: ServiceFailureResetPeriod::After(Duration::from_secs(24 * 60 * 60)), + reboot_msg: None, + command: None, + actions: Some( + (0..3) + .map(|_| ServiceAction { + action_type: ServiceActionType::Restart, + delay: RESTART_AFTER, + }) + .collect(), + ), + })?; + service.set_failure_actions_on_non_crash_failures(true)?; + service.start::<&str>(&[])?; + Ok(()) + } + + pub fn uninstall() -> Result<()> { + let manager = manager(ServiceManagerAccess::CONNECT)?; + let service = open( + &manager, + ServiceAccess::QUERY_STATUS | ServiceAccess::STOP | ServiceAccess::DELETE, + )? + .context("The PSF Guard Worker service is not installed")?; + stop_and_wait(&service)?; + service.delete()?; + drop(service); + // The settings contain the token. + let _ = std::fs::remove_dir_all(data()); + if let Ok(binary) = binary() { + let _ = std::fs::remove_file(&binary); + if let Some(folder) = binary.parent() { + let _ = std::fs::remove_dir(folder); + } + } + Ok(()) + } + + pub fn state() -> Option { + let manager = + ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT).ok()?; + let service = manager + .open_service(SERVICE_NAME, ServiceAccess::QUERY_STATUS) + .ok()?; + Some(match service.query_status().ok()?.current_state { + State::Running => ServiceState::Running, + State::Stopped => ServiceState::Stopped, + _ => ServiceState::Changing, + }) + } + + pub fn run(settings: PathBuf) -> Result<()> { + init_logging(); + let _ = SETTINGS.set(settings); + service_dispatcher::start(SERVICE_NAME, ffi_service_main).context( + "This command is for Windows to start the service; use `psf-guard worker` in a terminal", + )?; + Ok(()) + } + + fn service_main(_arguments: Vec) { + if let Err(error) = serve() { + tracing::error!("The worker service could not run: {error:#}"); + } + } + + fn status(state: State, wait: Duration, checkpoint: u32, failed: bool) -> ServiceStatus { + ServiceStatus { + service_type: ServiceType::OWN_PROCESS, + current_state: state, + controls_accepted: if state == State::Running { + ServiceControlAccept::STOP | ServiceControlAccept::PRESHUTDOWN + } else { + ServiceControlAccept::empty() + }, + // A nonzero code tells Windows the service failed, so its + // failure actions start it again. + exit_code: if failed { + ServiceExitCode::ServiceSpecific(1) + } else { + ServiceExitCode::Win32(0) + }, + checkpoint, + wait_hint: wait, + process_id: None, + } + } + + fn serve() -> Result<()> { + let control = WorkerControl::default(); + let handle: Arc> = Default::default(); + let stopping = Arc::new(AtomicBool::new(false)); + let registered = { + let (control, handle, stopping) = (control.clone(), handle.clone(), stopping.clone()); + service_control_handler::register(SERVICE_NAME, move |event| match event { + ServiceControl::Stop | ServiceControl::Preshutdown => { + if !stopping.swap(true, Ordering::SeqCst) + && let Some(handle) = handle.get().copied() + { + // Running tasks stop and the worker leaves the + // server, which queues them again. Until it has, + // Windows hears every few seconds that the stop is + // still going. + let stopping = stopping.clone(); + std::thread::spawn(move || { + let checkpoint = AtomicU32::new(1); + while stopping.load(Ordering::SeqCst) { + let _ = handle.set_service_status(status( + State::StopPending, + STOP_PROGRESS * 2, + checkpoint.fetch_add(1, Ordering::SeqCst), + false, + )); + std::thread::sleep(STOP_PROGRESS); + } + }); + } + control.quit(); + ServiceControlHandlerResult::NoError + } + ServiceControl::Interrogate => ServiceControlHandlerResult::NoError, + _ => ServiceControlHandlerResult::NotImplemented, + })? + }; + let _ = handle.set(registered); + let settings = SETTINGS.get().cloned(); + registered.set_service_status(status(State::Running, Duration::ZERO, 0, false))?; + let ran = match settings { + Some(settings) => tokio::runtime::Runtime::new() + .map_err(anyhow::Error::from) + .and_then(|runtime| { + let ran = runtime.block_on(run_with(WorkerLaunch::Settings(settings), control)); + // The worker has left the server; nothing it started is + // left to wait for. + runtime.shutdown_timeout(Duration::from_secs(5)); + ran + }), + None => Err(anyhow::anyhow!( + "The service started without its settings file" + )), + }; + // Log the reason before Windows hears the service stopped, since the + // process may end as soon as it does. + if let Err(error) = &ran { + tracing::error!("The worker stopped: {error:#}"); + } + stopping.store(false, Ordering::SeqCst); + registered.set_service_status(status(State::Stopped, Duration::ZERO, 0, ran.is_err()))?; + Ok(()) + } + + /// Log to files starting `worker` in `%ProgramData%\PSF Guard + /// Worker\logs`: a service has no console. + fn init_logging() { + use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, Layer}; + let file = crate::desktop_log::worker_file_appender(&data().join("logs")) + .ok() + .map(|appender| { + tracing_subscriber::fmt::layer() + .with_ansi(false) + .with_target(false) + .with_writer(appender) + .with_filter( + tracing_subscriber::filter::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::filter::EnvFilter::new("info")), + ) + }); + let _ = tracing_subscriber::registry().with(file).try_init(); + } +} + +#[cfg(not(windows))] +mod platform { + use super::*; + + const ELSEWHERE: &str = "This command is available only on Windows. On Linux, use the psf-guard-worker \ + systemd service. On macOS, open PSF Guard and choose Settings › Workers › Worker on this computer."; + + pub fn install(_settings: crate::commands::worker::WorkerSettings) -> Result<()> { + anyhow::bail!(ELSEWHERE) + } + + pub fn uninstall() -> Result<()> { + anyhow::bail!(ELSEWHERE) + } + + pub fn run(_settings: PathBuf) -> Result<()> { + anyhow::bail!(ELSEWHERE) + } + + pub fn state() -> Option { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_service_runs_its_own_copy_of_the_settings_from_a_locked_folder() { + let settings = Path::new(r"C:\ProgramData\PSF Guard Worker\worker.json"); + assert_eq!( + launch_arguments(settings), + [ + "worker-service", + "run", + "--config", + r"C:\ProgramData\PSF Guard Worker\worker.json" + ] + .map(std::ffi::OsString::from) + ); + let tasks = Path::new(r"C:\ProgramData\PSF Guard Worker\tasks"); + let saved = super::super::WorkerSettings { + server: "http://observatory:3000".into(), + token: "psfw_secret".into(), + work_dir: Some(r"C:\Users\ann\scratch".into()), + ..Default::default() + }; + assert_eq!( + service_settings(saved, tasks).work_dir.as_deref(), + Some(r"C:\ProgramData\PSF Guard Worker\tasks") + ); + } + + #[test] + fn installing_needs_settings_that_can_run() { + let dir = tempfile::tempdir().unwrap(); + let missing = dir.path().join("worker.json"); + assert!(install(&missing) + .unwrap_err() + .to_string() + .contains("Settings › Workers › Worker on this computer")); + std::fs::write( + &missing, + r#"{"server": "observatory:3000", "token": "psfw_secret"}"#, + ) + .unwrap(); + assert!(install(&missing) + .unwrap_err() + .to_string() + .contains("http://")); + } +} diff --git a/src/commands/worker/service/permissions.rs b/src/commands/worker/service/permissions.rs new file mode 100644 index 00000000..c627e973 --- /dev/null +++ b/src/commands/worker/service/permissions.rs @@ -0,0 +1,112 @@ +//! Restrict the service's data folder, then let its contents inherit that ACL. + +use std::path::Path; +use std::process::Command; + +use anyhow::{Context, Result}; + +pub(super) fn lock_down(folder: &Path, account: &str) -> Result<()> { + // Do not recurse here: /inheritance:r on each child removes the ACEs + // just inherited from this folder and can leave worker.json unreadable. + let locked = Command::new("icacls") + .arg(folder) + .args([ + "/inheritance:r", + "/grant:r", + "*S-1-5-18:(OI)(CI)F", + "*S-1-5-32-544:(OI)(CI)F", + &format!("{account}:(OI)(CI)M"), + "/Q", + ]) + .status() + .context("restricting the worker service data folder with icacls")?; + anyhow::ensure!( + locked.success(), + "Could not set permissions on {} to allow only SYSTEM, Administrators and the PSF Guard Worker service account ({locked})", + folder.display() + ); + + // Reset only the contents, never the protected root. This also repairs + // children whose inheritance an earlier installation disabled, and gives + // a replacement settings file the same permissions on reinstall. + let inherited = Command::new("icacls") + .arg(folder.join("*")) + .args(["/reset", "/T", "/Q"]) + .status() + .context("restoring worker service file inheritance with icacls")?; + anyhow::ensure!( + inherited.success(), + "Could not restore file permissions in {} ({inherited}). Run worker-service install from PowerShell opened as administrator.", + folder.display() + ); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + fn powershell(script: &str, folder: &Path) -> std::process::Output { + let result = Command::new("powershell.exe") + .args(["-NoProfile", "-NonInteractive", "-Command", script]) + // A parent PowerShell 7 may export module paths incompatible + // with the Windows PowerShell used by this test. + .env_remove("PSModulePath") + .env("PSF_GUARD_ACL_TEST_FOLDER", folder) + .output() + .unwrap(); + assert!( + result.status.success(), + "{}\n{}", + String::from_utf8_lossy(&result.stdout), + String::from_utf8_lossy(&result.stderr) + ); + result + } + + #[test] + fn settings_and_task_files_inherit_permissions_on_install_and_reinstall() { + let temp = tempfile::tempdir().unwrap(); + let folder = temp.path().join("Worker data with spaces"); + std::fs::create_dir_all(folder.join("tasks/nested")).unwrap(); + std::fs::write(folder.join("worker.json"), "test token").unwrap(); + std::fs::write(folder.join("tasks/nested/output.fits"), "test output").unwrap(); + let user = powershell( + "[Security.Principal.WindowsIdentity]::GetCurrent().User.Value", + &folder, + ); + // Substitute the test user's SID for the service account. No service + // installation or administrator privileges are needed in Windows CI. + let account = format!("*{}", String::from_utf8(user.stdout).unwrap().trim()); + lock_down(&folder, &account).unwrap(); + powershell(include_str!("permissions.tests.ps1"), &folder); + + // Reproduce the old installer's empty protected child ACL, including + // a nested task file, before checking that reinstall repairs it. + powershell( + r#" +$ErrorActionPreference = 'Stop' +$root = $env:PSF_GUARD_ACL_TEST_FOLDER +foreach ($name in @('worker.json', 'tasks/nested/output.fits')) { + $path = Join-Path $root $name + $acl = Get-Acl -LiteralPath $path + $acl.SetAccessRuleProtection($true, $false) + Set-Acl -LiteralPath $path -AclObject $acl + if ((Get-Acl -LiteralPath $path).Access.Count -ne 0) { throw 'Expected an empty ACL' } +} +"#, + &folder, + ); + lock_down(&folder, &account).unwrap(); + powershell(include_str!("permissions.tests.ps1"), &folder); + + // Atomic settings replacement and newly created task files must + // remain accessible without applying a separate file-specific ACL. + let mut replacement = tempfile::NamedTempFile::new_in(&folder).unwrap(); + replacement.write_all(b"test token").unwrap(); + replacement.persist(folder.join("worker.json")).unwrap(); + std::fs::write(folder.join("tasks/new.fits"), "new output").unwrap(); + powershell(include_str!("permissions.tests.ps1"), &folder); + } +} diff --git a/src/commands/worker/service/permissions.tests.ps1 b/src/commands/worker/service/permissions.tests.ps1 new file mode 100644 index 00000000..0de91d00 --- /dev/null +++ b/src/commands/worker/service/permissions.tests.ps1 @@ -0,0 +1,22 @@ +$ErrorActionPreference = 'Stop' +$root = $env:PSF_GUARD_ACL_TEST_FOLDER +$user = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value +$expected = @('S-1-5-18', 'S-1-5-32-544', $user) | Sort-Object +$paths = @((Get-Item -LiteralPath $root)) + @(Get-ChildItem -LiteralPath $root -Recurse -Force) +foreach ($item in $paths) { + $acl = Get-Acl -LiteralPath $item.FullName + $isRoot = $item.FullName -eq $root + if ($acl.AreAccessRulesProtected -ne $isRoot) { throw "Wrong inheritance: $($item.FullName)" } + $actual = @($acl.Access | ForEach-Object { $_.IdentityReference.Translate([Security.Principal.SecurityIdentifier]).Value }) | Sort-Object + if (Compare-Object $expected $actual) { throw "Unexpected access list: $($item.FullName)" } + foreach ($ace in $acl.Access) { + if ($ace.AccessControlType -ne 'Allow' -or $ace.IsInherited -eq $isRoot) { throw "Wrong ACE: $($item.FullName)" } + $sid = $ace.IdentityReference.Translate([Security.Principal.SecurityIdentifier]).Value + $rights = if ($sid -eq $user) { [Security.AccessControl.FileSystemRights]::Modify } else { [Security.AccessControl.FileSystemRights]::FullControl } + if (($ace.FileSystemRights -band $rights) -ne $rights) { throw "Missing rights for $sid" } + } +} +if ((Get-Content -LiteralPath (Join-Path $root 'worker.json') -Raw).Trim() -ne 'test token') { throw 'Cannot read the settings' } +$probe = Join-Path $root 'tasks/write-probe' +[IO.File]::WriteAllText($probe, 'writable') +Remove-Item -LiteralPath $probe diff --git a/src/commands/worker/service/process.rs b/src/commands/worker/service/process.rs new file mode 100644 index 00000000..3b329a98 --- /dev/null +++ b/src/commands/worker/service/process.rs @@ -0,0 +1,70 @@ +//! Wait for a stopped service's process before replacing its executable. + +use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; +use std::time::Duration; + +use anyhow::{Context, Result}; +use windows_sys::Win32::Foundation::{ERROR_INVALID_PARAMETER, WAIT_OBJECT_0, WAIT_TIMEOUT}; +use windows_sys::Win32::System::Threading::{ + OpenProcess, WaitForSingleObject, PROCESS_SYNCHRONIZE, +}; + +pub(super) struct Process(OwnedHandle); + +impl Process { + pub(super) fn open(pid: u32) -> Result> { + // Only synchronization access is needed; this handle cannot change + // the service process or inspect its memory. + let handle = unsafe { OpenProcess(PROCESS_SYNCHRONIZE, 0, pid) }; + if handle.is_null() { + let error = std::io::Error::last_os_error(); + if error.raw_os_error() == Some(ERROR_INVALID_PARAMETER as i32) { + // The worker exited between the status query and OpenProcess. + return Ok(None); + } + return Err(error).context("opening the worker service process to wait for its exit"); + } + // OpenProcess returned a new owned handle, closed by OwnedHandle. + Ok(Some(Self(unsafe { OwnedHandle::from_raw_handle(handle) }))) + } + + pub(super) fn wait(&self, timeout: Duration) -> Result<()> { + let milliseconds = timeout.as_millis().min(u128::from(u32::MAX - 1)) as u32; + match unsafe { WaitForSingleObject(self.0.as_raw_handle(), milliseconds) } { + WAIT_OBJECT_0 => Ok(()), + WAIT_TIMEOUT => anyhow::bail!( + "The PSF Guard Worker process is still stopping; try again in a minute" + ), + _ => Err(std::io::Error::last_os_error()) + .context("waiting for the worker service process to exit"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use std::process::{Command, Stdio}; + + #[test] + fn waits_for_process_exit_after_the_worker_has_reported_stopped() { + // Hold the child at stdin, as a worker can remain alive during + // shutdown after publishing its final status to the SCM. + let mut child = Command::new("powershell.exe") + .args([ + "-NoProfile", + "-NonInteractive", + "-Command", + "[Console]::In.ReadLine() | Out-Null", + ]) + .stdin(Stdio::piped()) + .spawn() + .unwrap(); + let process = Process::open(child.id()).unwrap().unwrap(); + assert!(process.wait(Duration::ZERO).is_err()); + child.stdin.take().unwrap().write_all(b"\n").unwrap(); + process.wait(Duration::from_secs(15)).unwrap(); + assert!(child.wait().unwrap().success()); + } +} diff --git a/src/desktop_worker.rs b/src/desktop_worker.rs index 2cc64e01..8caff8ca 100644 --- a/src/desktop_worker.rs +++ b/src/desktop_worker.rs @@ -41,6 +41,8 @@ pub struct DesktopWorker { pub start_at_login: bool, /// The running worker's status, or `None` when it is not running. pub status: Option, + /// The Windows service's state, when the service is installed. + pub service: Option, pub settings_path: String, } @@ -91,6 +93,9 @@ async fn describe() -> Result { }), start_at_login: login::enabled(), status: running_status().await?, + service: tokio::task::spawn_blocking(crate::commands::worker::service::state) + .await + .unwrap_or(None), settings_path: path.display().to_string(), }) } diff --git a/static/e2e/desktop-worker-settings.spec.ts b/static/e2e/desktop-worker-settings.spec.ts index 058deabf..66d2aa58 100644 --- a/static/e2e/desktop-worker-settings.spec.ts +++ b/static/e2e/desktop-worker-settings.spec.ts @@ -18,10 +18,12 @@ test.beforeEach(async ({ page, request }) => { has_token: false, start_at_login: false, status: null, + service: null, settings_path: '/Users/ann/Library/Application Support/psf-guard/worker.json', }; const calls: Array<{ command: string; args: unknown }> = []; (window as unknown as { __desktopWorkerCalls: typeof calls }).__desktopWorkerCalls = calls; + (window as unknown as { __desktopWorkerState: Worker }).__desktopWorkerState = worker; const origin = window.location.origin; const commands: Record) => unknown> = { get_server_url: () => origin, @@ -121,3 +123,31 @@ test('this computer saves its worker settings, then starts and stops the worker' expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390); await page.screenshot({ path: testInfo.outputPath('worker-on-this-computer-phone.png') }); }); + +for (const service of ['running', 'changing'] as const) { + test(`Windows service ${service} explains how to apply saved settings`, async ({ page }, testInfo) => { + await page.setViewportSize({ width: 1280, height: 1400 }); + await page.goto('/'); + await page.evaluate((state) => { + const worker = (window as unknown as { __desktopWorkerState: Record }).__desktopWorkerState; + worker.service = state; + worker.has_token = true; + worker.settings = { + server: 'http://observatory.local:3000', name: 'studio-pc', files: 'remote', maps: [], + work_dir: null, rc_astro: null, work: [], slots: 1, + }; + }, service); + await openSettings(page); + await page.getByRole('tab', { name: 'Workers' }).click(); + const computer = page.getByRole('region', { name: 'Worker on this computer' }); + const status = computer.getByRole('group', { name: 'Worker status' }); + await expect(status).toContainText(service === 'running' + ? 'The PSF Guard Worker Windows service is running.' + : 'The PSF Guard Worker Windows service has not stopped.'); + await expect(status.getByRole('button', { name: 'Start worker' })).toBeDisabled(); + await expect(status).toContainText('Save worker settings'); + await expect(status).toContainText('psf-guard-cli worker-service install'); + await expect(status).not.toContainText('Connected to'); + await status.screenshot({ path: testInfo.outputPath(`windows-service-${service}.png`) }); + }); +} diff --git a/static/src/components/DesktopWorkerSettings.tsx b/static/src/components/DesktopWorkerSettings.tsx index 85b40ffe..3a49c550 100644 --- a/static/src/components/DesktopWorkerSettings.tsx +++ b/static/src/components/DesktopWorkerSettings.tsx @@ -123,6 +123,9 @@ export default function DesktopWorkerSettings() { } const status = loaded.status; + // One worker per computer: while the Windows service runs one, the page + // offers no second. + const serviceRuns = loaded.service === 'running' || loaded.service === 'changing'; const dirty = draft.token.trim() !== '' || !sameSettings(draft, loaded); const busy = action.isPending; const update = (change: Partial) => setDraft({ ...draft, ...change }); @@ -176,12 +179,29 @@ export default function DesktopWorkerSettings() {

-

{status ? statusText(status) : 'Not running.'}

+

+ {status + ? statusText(status) + : loaded.service === 'running' + ? 'The PSF Guard Worker Windows service is running.' + : serviceRuns + ? 'The PSF Guard Worker Windows service has not stopped. Stop it in Windows Services to use the desktop worker.' + : 'Not running.'} +

{status?.tasks.map((task) => (

{task.label}: {Math.round(task.fraction * 100)}%

))} + {serviceRuns && !status && ( +

+ To update the service, click Save worker settings, then run{' '} + psf-guard-cli worker-service install in PowerShell opened + as administrator. This command updates the service's copies of + the app and settings, then restarts the service. The desktop worker + controls do not control the Windows service. +

+ )}
{status ? ( <> @@ -209,7 +229,7 @@ export default function DesktopWorkerSettings() {
- {!status && (!loaded.settings || !loaded.has_token) && ( + {!status && !serviceRuns && (!loaded.settings || !loaded.has_token) && (

Save a server address and a worker token before starting the worker.

)} - {!status && loaded.settings && loaded.has_token && dirty && ( + {!status && !serviceRuns && loaded.settings && loaded.has_token && dirty && (

Save your changes before starting the worker.

)} -

+ {!serviceRuns &&

Pause lets running tasks finish. A task already requested from the server may still start. Stop returns unfinished tasks to the server's queue. -

+

}
@@ -399,6 +419,10 @@ export default function DesktopWorkerSettings() {

Leave blank to use the system temporary folder. The worker stores task files here while tasks run.

+ {loaded.service != null &&

+ The Windows service uses %ProgramData%\PSF Guard Worker\tasks instead. + Temporary task folder applies to the desktop worker. +

}
diff --git a/static/src/components/__tests__/DesktopWorkerSettings.test.tsx b/static/src/components/__tests__/DesktopWorkerSettings.test.tsx index d5274734..2e4f3752 100644 --- a/static/src/components/__tests__/DesktopWorkerSettings.test.tsx +++ b/static/src/components/__tests__/DesktopWorkerSettings.test.tsx @@ -22,6 +22,7 @@ beforeEach(() => { has_token: false, start_at_login: false, status: null, + service: null, settings_path: '/home/ann/.config/psf-guard/worker.json', }; native.get.mockImplementation(async () => worker); @@ -121,6 +122,24 @@ describe('this computer\'s worker', () => { expect(native.save.mock.calls[0][0]).toMatchObject({ token: null, work: ['stack'], slots: 2, name: 'studio-pc' }); }); + it.each(['running', 'changing'] as const)('prevents a desktop worker while the Windows service is %s', async (service) => { + worker = { + ...worker, + has_token: true, + service, + settings: { + server: 'http://observatory:3000', name: null, files: 'remote', maps: [], + work_dir: null, rc_astro: null, work: [], slots: 1, + }, + }; + mount(); + expect(await screen.findByText(service === 'running' + ? 'The PSF Guard Worker Windows service is running.' + : 'The PSF Guard Worker Windows service has not stopped. Stop it in Windows Services to use the desktop worker.')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Start worker' })).toBeDisabled(); + expect(screen.getByText(/updates the service's copies/)).toBeInTheDocument(); + }); + it('points Linux at the systemd service', async () => { worker = { ...worker, supported: false }; mount(); diff --git a/static/src/utils/tauri.ts b/static/src/utils/tauri.ts index 23544af8..af277b82 100644 --- a/static/src/utils/tauri.ts +++ b/static/src/utils/tauri.ts @@ -224,6 +224,8 @@ export interface DesktopWorker { start_at_login: boolean; /** `null` while the worker is not running. */ status: DesktopWorkerStatus | null; + /** The Windows service's state, when the service is installed. */ + service: 'running' | 'stopped' | 'changing' | null; settings_path: string; }