1use crate::cli::Cli;
26use std::path::PathBuf;
27
28pub const SERVICE_NAME: &str = "BhtuneServer";
31pub const SERVICE_DISPLAY_NAME: &str = "BHTune Server";
33pub const SERVICE_DESCRIPTION: &str = "Serves BHTune's HTTP API and embedded web GUI for MRFT PID auto-tuning. \
35 https://github.com/bytehound-labs/bhtune";
36
37#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct ServiceDefinition {
43 pub name: String,
44 pub display_name: String,
45 pub description: String,
46 pub executable_path: PathBuf,
47 pub launch_arguments: Vec<String>,
48}
49
50pub fn service_launch_arguments(cli: &Cli) -> Vec<String> {
59 let mut args = Vec::new();
60 if let Some(config) = &cli.config {
61 args.push("--config".to_string());
62 args.push(config.display().to_string());
63 }
64 args
65}
66
67pub fn build_service_definition(executable_path: PathBuf, cli: &Cli) -> ServiceDefinition {
71 ServiceDefinition {
72 name: SERVICE_NAME.to_string(),
73 display_name: SERVICE_DISPLAY_NAME.to_string(),
74 description: SERVICE_DESCRIPTION.to_string(),
75 executable_path,
76 launch_arguments: service_launch_arguments(cli),
77 }
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum ServiceLifecycle {
87 StartPending,
90 Running,
93 StopPending,
96 Stopped,
98}
99
100impl ServiceLifecycle {
101 pub fn next(self) -> Option<Self> {
105 match self {
106 ServiceLifecycle::StartPending => Some(ServiceLifecycle::Running),
107 ServiceLifecycle::Running => Some(ServiceLifecycle::StopPending),
108 ServiceLifecycle::StopPending => Some(ServiceLifecycle::Stopped),
109 ServiceLifecycle::Stopped => None,
110 }
111 }
112}
113
114const ERROR_FAILED_SERVICE_CONTROLLER_CONNECT: i32 = 1063;
118
119pub fn is_scm_launch_error_code(code: Option<i32>) -> bool {
126 code == Some(ERROR_FAILED_SERVICE_CONTROLLER_CONNECT)
127}
128
129#[cfg(not(target_os = "windows"))]
133fn platform_service_error(action: &str) -> anyhow::Error {
134 anyhow::anyhow!(
135 "`bhtune-server {action}` manages a Windows service and only works on Windows.\n\
136 On Linux, install the provided systemd unit instead:\n \
137 packaging/systemd/bhtune-server.service\n\
138 On macOS, install the provided launchd daemon instead:\n \
139 packaging/launchd/com.bytehound-labs.bhtune-server.plist\n\
140 See docs/getting-started/installation.md#run-as-a-background-service for the exact \
141 steps."
142 )
143}
144
145#[cfg(target_os = "windows")]
146mod windows_impl {
147 use super::{
148 SERVICE_DISPLAY_NAME, SERVICE_NAME, ServiceDefinition, ServiceLifecycle,
149 build_service_definition, is_scm_launch_error_code,
150 };
151 use crate::cli::Cli;
152 use crate::run;
153 use std::ffi::OsString;
154 use std::time::Duration;
155 use windows_service::service::{
156 ServiceAccess, ServiceControl, ServiceControlAccept, ServiceErrorControl, ServiceExitCode,
157 ServiceInfo, ServiceStartType, ServiceState, ServiceStatus, ServiceType,
158 };
159 use windows_service::service_control_handler::ServiceStatusHandle;
160 use windows_service::service_control_handler::{self, ServiceControlHandlerResult};
161 use windows_service::service_dispatcher;
162 use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
163
164 const SERVICE_TYPE: ServiceType = ServiceType::OWN_PROCESS;
165
166 windows_service::define_windows_service!(ffi_service_main, service_main);
167
168 fn to_service_info(definition: &ServiceDefinition) -> ServiceInfo {
172 ServiceInfo {
173 name: OsString::from(&definition.name),
174 display_name: OsString::from(&definition.display_name),
175 service_type: SERVICE_TYPE,
176 start_type: ServiceStartType::AutoStart,
177 error_control: ServiceErrorControl::Normal,
178 executable_path: definition.executable_path.clone(),
179 launch_arguments: definition
180 .launch_arguments
181 .iter()
182 .map(OsString::from)
183 .collect(),
184 dependencies: vec![],
185 account_name: None, account_password: None,
187 }
188 }
189
190 pub fn install(cli: &Cli) -> anyhow::Result<()> {
192 let exe = std::env::current_exe()?;
195 let definition = build_service_definition(exe, cli);
196 let manager = ServiceManager::local_computer(
197 None::<&str>,
198 ServiceManagerAccess::CONNECT | ServiceManagerAccess::CREATE_SERVICE,
199 )?;
200 let service =
201 manager.create_service(&to_service_info(&definition), ServiceAccess::CHANGE_CONFIG)?;
202 service.set_description(&definition.description)?;
203 println!(
204 "Installed '{}' ({}). Start it with: bhtune-server.exe start",
205 definition.display_name, definition.name
206 );
207 Ok(())
208 }
209
210 pub fn uninstall() -> anyhow::Result<()> {
212 let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)?;
213 let service = manager.open_service(
214 SERVICE_NAME,
215 ServiceAccess::QUERY_STATUS | ServiceAccess::STOP | ServiceAccess::DELETE,
216 )?;
217 if service.query_status()?.current_state != ServiceState::Stopped {
218 service.stop()?;
219 }
220 service.delete()?;
221 println!("Uninstalled '{SERVICE_DISPLAY_NAME}'.");
222 Ok(())
223 }
224
225 pub fn start() -> anyhow::Result<()> {
227 let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)?;
228 let service = manager.open_service(SERVICE_NAME, ServiceAccess::START)?;
229 service.start::<&std::ffi::OsStr>(&[])?;
230 println!("Started '{SERVICE_DISPLAY_NAME}'.");
231 Ok(())
232 }
233
234 pub fn stop() -> anyhow::Result<()> {
236 let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)?;
237 let service = manager.open_service(SERVICE_NAME, ServiceAccess::STOP)?;
238 service.stop()?;
239 println!("Stop requested for '{SERVICE_DISPLAY_NAME}'.");
240 Ok(())
241 }
242
243 pub fn status() -> anyhow::Result<()> {
245 let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)?;
246 let service = manager.open_service(SERVICE_NAME, ServiceAccess::QUERY_STATUS)?;
247 let status = service.query_status()?;
248 println!("{SERVICE_DISPLAY_NAME}: {:?}", status.current_state);
249 Ok(())
250 }
251
252 pub fn is_run_outside_scm(err: &windows_service::Error) -> bool {
256 matches!(
257 err,
258 windows_service::Error::Winapi(io_err) if is_scm_launch_error_code(io_err.raw_os_error())
259 )
260 }
261
262 pub fn run_as_service() -> windows_service::Result<()> {
266 service_dispatcher::start(SERVICE_NAME, ffi_service_main)
267 }
268
269 fn report_status(
276 handle: &ServiceStatusHandle,
277 state: ServiceLifecycle,
278 ) -> windows_service::Result<()> {
279 let (current_state, controls_accepted, wait_hint) = match state {
280 ServiceLifecycle::StartPending => (
281 ServiceState::StartPending,
282 ServiceControlAccept::empty(),
283 Duration::from_secs(10),
284 ),
285 ServiceLifecycle::Running => (
286 ServiceState::Running,
287 ServiceControlAccept::STOP | ServiceControlAccept::SHUTDOWN,
288 Duration::default(),
289 ),
290 ServiceLifecycle::StopPending => (
291 ServiceState::StopPending,
292 ServiceControlAccept::empty(),
293 Duration::from_secs(40),
294 ),
295 ServiceLifecycle::Stopped => (
296 ServiceState::Stopped,
297 ServiceControlAccept::empty(),
298 Duration::default(),
299 ),
300 };
301 handle.set_service_status(ServiceStatus {
302 service_type: SERVICE_TYPE,
303 current_state,
304 controls_accepted,
305 exit_code: ServiceExitCode::Win32(0),
306 checkpoint: 0,
307 wait_hint,
308 process_id: None,
309 })
310 }
311
312 fn service_main(_arguments: Vec<OsString>) {
318 if let Err(e) = run_service() {
319 eprintln!("bhtune-server service run failed: {e:?}");
323 }
324 }
325
326 fn run_service() -> anyhow::Result<()> {
327 use clap::Parser;
328
329 let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
330 let shutdown_tx = std::sync::Mutex::new(Some(shutdown_tx));
331
332 let event_handler = move |control_event| -> ServiceControlHandlerResult {
333 match control_event {
334 ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
336 ServiceControl::Stop | ServiceControl::Shutdown => {
337 if let Some(tx) = shutdown_tx.lock().unwrap_or_else(|e| e.into_inner()).take() {
338 let _ = tx.send(());
339 }
340 ServiceControlHandlerResult::NoError
341 }
342 _ => ServiceControlHandlerResult::NotImplemented,
343 }
344 };
345
346 let status_handle = service_control_handler::register(SERVICE_NAME, event_handler)?;
347 report_status(&status_handle, ServiceLifecycle::StartPending)?;
348
349 let cli = Cli::parse();
352
353 let rt = tokio::runtime::Runtime::new()?;
354 let server = rt.block_on(run::build_server(cli.config.as_deref()))?;
360 report_status(&status_handle, ServiceLifecycle::Running)?;
361
362 let stop_status_handle = status_handle;
368 let shutdown = async move {
369 let _ = shutdown_rx.await;
370 let _ = report_status(&stop_status_handle, ServiceLifecycle::StopPending);
371 };
372
373 let result = rt.block_on(run::serve(server, shutdown));
374
375 report_status(&status_handle, ServiceLifecycle::Stopped)?;
376 result
377 }
378}
379
380#[cfg(target_os = "windows")]
381pub use windows_impl::{
382 install, is_run_outside_scm, run_as_service, start, status, stop, uninstall,
383};
384
385#[cfg(not(target_os = "windows"))]
386pub fn install(_cli: &Cli) -> anyhow::Result<()> {
387 Err(platform_service_error("install"))
388}
389
390#[cfg(not(target_os = "windows"))]
391pub fn uninstall() -> anyhow::Result<()> {
392 Err(platform_service_error("uninstall"))
393}
394
395#[cfg(not(target_os = "windows"))]
396pub fn start() -> anyhow::Result<()> {
397 Err(platform_service_error("start"))
398}
399
400#[cfg(not(target_os = "windows"))]
401pub fn stop() -> anyhow::Result<()> {
402 Err(platform_service_error("stop"))
403}
404
405#[cfg(not(target_os = "windows"))]
406pub fn status() -> anyhow::Result<()> {
407 Err(platform_service_error("status"))
408}
409
410#[cfg(test)]
411mod tests {
412 use super::*;
413 use std::path::PathBuf;
414
415 fn cli_with(config: Option<&str>) -> Cli {
416 Cli {
417 command: None,
418 config: config.map(PathBuf::from),
419 }
420 }
421
422 #[test]
423 fn service_launch_arguments_empty_when_no_config_flag_set() {
424 let cli = cli_with(None);
425 assert_eq!(service_launch_arguments(&cli), Vec::<String>::new());
426 }
427
428 #[test]
429 fn service_launch_arguments_includes_the_config_flag_when_set() {
430 let cli = cli_with(Some("/etc/bhtune/bhtune.toml"));
431 assert_eq!(
432 service_launch_arguments(&cli),
433 vec![
434 "--config".to_string(),
435 "/etc/bhtune/bhtune.toml".to_string(),
436 ]
437 );
438 }
439
440 #[test]
441 fn build_service_definition_carries_identity_and_arguments() {
442 let cli = cli_with(Some("C:\\ProgramData\\bhtune\\bhtune.toml"));
443 let definition = build_service_definition(PathBuf::from("C:\\bhtune-server.exe"), &cli);
444 assert_eq!(definition.name, SERVICE_NAME);
445 assert_eq!(definition.display_name, SERVICE_DISPLAY_NAME);
446 assert_eq!(definition.description, SERVICE_DESCRIPTION);
447 assert_eq!(
448 definition.executable_path,
449 PathBuf::from("C:\\bhtune-server.exe")
450 );
451 assert_eq!(
452 definition.launch_arguments,
453 vec![
454 "--config".to_string(),
455 "C:\\ProgramData\\bhtune\\bhtune.toml".to_string(),
456 ]
457 );
458 }
459
460 #[test]
461 fn build_service_definition_with_no_flags_has_no_launch_arguments() {
462 let cli = cli_with(None);
463 let definition = build_service_definition(PathBuf::from("/usr/bin/bhtune-server"), &cli);
464 assert_eq!(definition.launch_arguments, Vec::<String>::new());
465 }
466
467 #[test]
468 fn service_lifecycle_sequence_order() {
469 assert_eq!(
470 ServiceLifecycle::StartPending.next(),
471 Some(ServiceLifecycle::Running)
472 );
473 assert_eq!(
474 ServiceLifecycle::Running.next(),
475 Some(ServiceLifecycle::StopPending)
476 );
477 assert_eq!(
478 ServiceLifecycle::StopPending.next(),
479 Some(ServiceLifecycle::Stopped)
480 );
481 }
482
483 #[test]
484 fn service_lifecycle_stopped_is_terminal() {
485 assert_eq!(ServiceLifecycle::Stopped.next(), None);
486 }
487
488 #[test]
489 fn is_scm_launch_error_code_matches_expected_code() {
490 assert!(is_scm_launch_error_code(Some(1063)));
491 }
492
493 #[test]
494 fn is_scm_launch_error_code_rejects_other_codes() {
495 assert!(!is_scm_launch_error_code(Some(5)));
496 assert!(!is_scm_launch_error_code(None));
497 }
498
499 #[cfg(not(target_os = "windows"))]
500 #[test]
501 fn non_windows_stubs_name_the_action_and_point_at_packaging() {
502 for (action, result) in [
503 ("install", install(&cli_with(None))),
504 ("uninstall", uninstall()),
505 ("start", start()),
506 ("stop", stop()),
507 ("status", status()),
508 ] {
509 let message = result.unwrap_err().to_string();
510 assert!(
511 message.contains(&format!("bhtune-server {action}")),
512 "message for {action} should name the action verbatim: {message}"
513 );
514 assert!(message.contains("packaging/systemd/bhtune-server.service"));
515 assert!(message.contains("packaging/launchd/"));
516 }
517 }
518}