Contractless/src/startup/windows_service.rs

589 lines
20 KiB
Rust
Raw Normal View History

2026-05-24 17:56:57 +00:00
#[cfg(windows)]
use crate::common::network_paths_and_settings::block_extension_and_paths;
#[cfg(windows)]
use crate::env;
#[cfg(windows)]
use crate::log::{error, info, warn};
#[cfg(windows)]
use crate::startup::initialize_startup::prepare_pre_wallet_startup;
#[cfg(windows)]
use crate::startup::node_runtime::{
initialize_node_logging, install_panic_cleanup, run_unlocked_node,
};
#[cfg(windows)]
use crate::startup::unlock_pipe::{pipe_name, run_unlock_pipe_server};
#[cfg(windows)]
use crate::startup::unlock_structs::ServiceWaitState;
#[cfg(windows)]
use crate::Arc;
#[cfg(windows)]
use crate::Duration;
#[cfg(windows)]
use crate::Error;
#[cfg(windows)]
use crate::Runtime;
#[cfg(windows)]
use crate::RwLock;
#[cfg(windows)]
use crate::{AtomicBool, AtomicOrdering};
#[cfg(windows)]
use std::ffi::OsString;
#[cfg(windows)]
use std::path::{Path, PathBuf};
#[cfg(windows)]
use std::sync::Mutex as StdMutex;
#[cfg(windows)]
use std::thread::sleep as thread_sleep;
#[cfg(windows)]
use std::time::{Duration as StdDuration, Instant};
#[cfg(windows)]
use tokio::sync::mpsc;
#[cfg(windows)]
use tokio::task::JoinHandle;
#[cfg(windows)]
use windows_service::service::{
ServiceAccess, ServiceErrorControl, ServiceInfo, ServiceStartType, ServiceType,
};
#[cfg(windows)]
use windows_service::service::{
ServiceControl, ServiceControlAccept, ServiceExitCode, ServiceState, ServiceStatus,
};
#[cfg(windows)]
use windows_service::service_control_handler::{self, ServiceControlHandlerResult};
#[cfg(windows)]
use windows_service::service_dispatcher;
#[cfg(windows)]
use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
#[cfg(windows)]
use windows_sys::Win32::Foundation::{ERROR_SERVICE_ALREADY_RUNNING, ERROR_SERVICE_DOES_NOT_EXIST};
#[cfg(windows)]
use winreg::enums::HKEY_LOCAL_MACHINE;
#[cfg(windows)]
use winreg::RegKey;
#[cfg(windows)]
const WINDOWS_SERVICE_CONTROLLER_CONNECT_ERROR: i32 = 1063;
#[cfg(windows)]
windows_service::define_windows_service!(ffi_service_main, contractless_service_main);
#[cfg(windows)]
fn service_name() -> &'static str {
#[cfg(feature = "mainnet")]
{
"ContractlessMainnet"
}
#[cfg(feature = "testnet")]
{
"ContractlessTestnet"
}
}
#[cfg(windows)]
fn service_display_name() -> &'static str {
#[cfg(feature = "mainnet")]
{
"Contractless Mainnet"
}
#[cfg(feature = "testnet")]
{
"Contractless Testnet"
}
}
#[cfg(windows)]
fn uninstall_registry_key_name() -> String {
// The uninstall entry shares the service identity so Windows can present the
// running node as a normal removable application in Add/Remove Programs.
format!(
r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{}",
service_name()
)
}
#[cfg(windows)]
fn submit_key_binary_path(service_binary_path: &Path) -> PathBuf {
// The helper binary is expected next to the service executable.
service_binary_path
.parent()
.unwrap_or_else(|| Path::new("."))
.join("contractless-submit-key.exe")
}
#[cfg(windows)]
fn quoted_command(path: &Path, args: &[&str]) -> String {
// Windows registry uninstall commands need quoted paths because Program Files contains spaces.
let mut command = format!("\"{}\"", path.display());
for arg in args {
command.push(' ');
command.push_str(arg);
}
command
}
#[cfg(windows)]
fn register_add_remove_entry(service_binary_path: &Path) -> Result<(), Box<dyn Error>> {
// The uninstall registry entry points back to the same binary so the install,
// service management, and uninstall logic all stay in one maintained path.
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
let uninstall_path = uninstall_registry_key_name();
let (key, _) = hklm.create_subkey(&uninstall_path)?;
let install_location = service_binary_path
.parent()
.unwrap_or_else(|| Path::new("."))
.to_string_lossy()
.to_string();
let uninstall_command = quoted_command(service_binary_path, &["--uninstall-service"]);
let submit_key_path = submit_key_binary_path(service_binary_path);
key.set_value(
"DisplayName",
&format!("{} Service", service_display_name()),
)?;
key.set_value("DisplayVersion", &env!("CARGO_PKG_VERSION"))?;
key.set_value("Publisher", &"Contractless")?;
key.set_value("InstallLocation", &install_location)?;
key.set_value(
"DisplayIcon",
&service_binary_path.to_string_lossy().to_string(),
)?;
key.set_value("UninstallString", &uninstall_command)?;
key.set_value("QuietUninstallString", &uninstall_command)?;
key.set_value(
"Comments",
&format!(
"{} background node service. Use contractless-submit-key.exe to unlock the wallet after the service starts.",
service_display_name()
),
)?;
key.set_value("NoModify", &1u32)?;
key.set_value("NoRepair", &1u32)?;
if submit_key_path.exists() {
key.set_value(
"HelpLink",
&format!(
"Submit wallet key with {} after starting the service.",
submit_key_path.display()
),
)?;
}
Ok(())
}
#[cfg(windows)]
fn unregister_add_remove_entry() -> Result<(), Box<dyn Error>> {
// Removing an already-missing uninstall entry should not make service uninstall fail.
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
let uninstall_path = uninstall_registry_key_name();
match hklm.delete_subkey_all(&uninstall_path) {
Ok(()) => Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(err.into()),
}
}
#[cfg(windows)]
pub fn try_run_as_windows_service() -> Result<bool, Box<dyn Error>> {
// Console launches fall through to the normal startup path. Only SCM launches
// should enter the dedicated Windows service runtime.
match service_dispatcher::start(service_name(), ffi_service_main) {
Ok(()) => Ok(true),
Err(windows_service::Error::Winapi(err))
if err.raw_os_error() == Some(WINDOWS_SERVICE_CONTROLLER_CONNECT_ERROR) =>
{
Ok(false)
}
Err(err) => Err(err.into()),
}
}
#[cfg(windows)]
pub fn handle_windows_service_command() -> Result<bool, Box<dyn Error>> {
let args: Vec<String> = env::args().skip(1).collect();
// Service install and lifecycle commands are handled before any wallet prompt
// or node startup so administrative setup never mixes with normal runtime flow.
if args.iter().any(|arg| arg == "--install-service") {
println!("{}", install_windows_service()?);
return Ok(true);
}
if args.iter().any(|arg| arg == "--uninstall-service") {
println!("{}", uninstall_windows_service()?);
return Ok(true);
}
if args.iter().any(|arg| arg == "--start-service") {
println!("{}", start_windows_service()?);
return Ok(true);
}
if args.iter().any(|arg| arg == "--stop-service") {
println!("{}", stop_windows_service()?);
return Ok(true);
}
Ok(false)
}
#[cfg(windows)]
fn install_windows_service() -> Result<String, Box<dyn Error>> {
// Installing the service also registers the uninstall entry so Windows has a
// standard removal path in addition to the direct CLI commands.
let manager_access = ServiceManagerAccess::CONNECT | ServiceManagerAccess::CREATE_SERVICE;
let service_manager = ServiceManager::local_computer(None::<&str>, manager_access)?;
let service_binary_path = env::current_exe()?;
let service_info = ServiceInfo {
name: OsString::from(service_name()),
display_name: OsString::from(service_display_name()),
service_type: ServiceType::OWN_PROCESS,
start_type: ServiceStartType::OnDemand,
error_control: ServiceErrorControl::Normal,
executable_path: service_binary_path.clone(),
launch_arguments: vec![],
dependencies: vec![],
account_name: None,
account_password: None,
};
let service_access = ServiceAccess::QUERY_CONFIG
| ServiceAccess::CHANGE_CONFIG
| ServiceAccess::START
| ServiceAccess::DELETE;
let service = match service_manager.create_service(&service_info, service_access) {
Ok(service) => {
let _ = service.set_description("Contractless blockchain node service");
register_add_remove_entry(&service_binary_path)?;
return Ok(format!("Windows service {} installed.", service_name()));
}
Err(_) => service_manager.open_service(service_name(), service_access)?,
};
let _ = service.set_description("Contractless blockchain node service");
register_add_remove_entry(&service_binary_path)?;
Ok(format!(
"Windows service {} is already installed.",
service_name()
))
}
#[cfg(windows)]
fn uninstall_windows_service() -> Result<String, Box<dyn Error>> {
// Uninstall first stops the service if needed, then removes both the SCM entry
// and the Add/Remove Programs registration.
let service_manager =
ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)?;
let service_access = ServiceAccess::QUERY_STATUS | ServiceAccess::STOP | ServiceAccess::DELETE;
let service = match service_manager.open_service(service_name(), service_access) {
Ok(service) => service,
Err(windows_service::Error::Winapi(err))
if err.raw_os_error() == Some(ERROR_SERVICE_DOES_NOT_EXIST as i32) =>
{
unregister_add_remove_entry()?;
return Ok(format!(
"Windows service {} is not installed.",
service_name()
));
}
Err(err) => return Err(err.into()),
};
stop_service_if_running(&service)?;
service.delete()?;
drop(service);
let start = Instant::now();
let timeout = StdDuration::from_secs(5);
while start.elapsed() < timeout {
match service_manager.open_service(service_name(), ServiceAccess::QUERY_STATUS) {
Err(windows_service::Error::Winapi(err))
if err.raw_os_error() == Some(ERROR_SERVICE_DOES_NOT_EXIST as i32) =>
{
unregister_add_remove_entry()?;
return Ok(format!("Windows service {} uninstalled.", service_name()));
}
Ok(service) => {
drop(service);
}
Err(err) => return Err(err.into()),
}
thread_sleep(StdDuration::from_millis(250));
}
unregister_add_remove_entry()?;
Ok(format!(
"Windows service {} is marked for deletion and will finish uninstalling once Windows releases the open handles.",
service_name()
))
}
#[cfg(windows)]
fn start_windows_service() -> Result<String, Box<dyn Error>> {
// Start is a management command; the service itself still waits for a wallet key over the pipe.
let service_manager =
ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)?;
let service = service_manager.open_service(
service_name(),
ServiceAccess::START | ServiceAccess::QUERY_STATUS,
)?;
match service.start(&[] as &[OsString]) {
Ok(()) => Ok(format!("Windows service {} started.", service_name())),
Err(windows_service::Error::Winapi(err))
if err.raw_os_error() == Some(ERROR_SERVICE_ALREADY_RUNNING as i32) =>
{
Ok(format!(
"Windows service {} is already running.",
service_name()
))
}
Err(err) => Err(err.into()),
}
}
#[cfg(windows)]
fn stop_windows_service() -> Result<String, Box<dyn Error>> {
// Stop requests go through SCM so service shutdown follows the normal control handler path.
let service_manager =
ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)?;
let service = match service_manager.open_service(
service_name(),
ServiceAccess::QUERY_STATUS | ServiceAccess::STOP,
) {
Ok(service) => service,
Err(windows_service::Error::Winapi(err))
if err.raw_os_error() == Some(ERROR_SERVICE_DOES_NOT_EXIST as i32) =>
{
return Ok(format!(
"Windows service {} is not installed.",
service_name()
));
}
Err(err) => return Err(err.into()),
};
if stop_service_if_running(&service)? {
Ok(format!(
"Windows service {} stop requested.",
service_name()
))
} else {
Ok(format!(
"Windows service {} is already stopped.",
service_name()
))
}
}
#[cfg(windows)]
fn stop_service_if_running(
service: &windows_service::service::Service,
) -> Result<bool, Box<dyn Error>> {
// Service deletion and rebuilds both depend on the process actually exiting, so
// the stop path waits until Windows reports the service as fully stopped.
let status = service.query_status()?;
if status.current_state == ServiceState::Stopped {
return Ok(false);
}
if status.current_state != ServiceState::StopPending {
let _ = service.stop()?;
}
let start = Instant::now();
let timeout = StdDuration::from_secs(15);
while start.elapsed() < timeout {
let status = service.query_status()?;
if status.current_state == ServiceState::Stopped {
return Ok(true);
}
thread_sleep(StdDuration::from_millis(250));
}
Err(format!(
"Timed out waiting for Windows service {} to stop.",
service_name()
)
.into())
}
#[cfg(windows)]
fn contractless_service_main(_arguments: Vec<OsString>) {
// SCM enters here instead of the normal console main path.
if let Err(err) = run_service() {
eprintln!("Windows service error: {err}");
}
}
#[cfg(windows)]
fn run_service() -> windows_service::Result<()> {
// The Windows service starts in a locked state, completes only the pre-wallet
// startup work, and then waits for the helper tool to submit the wallet key.
let shutdown = Arc::new(AtomicBool::new(false));
let shutdown_handler = shutdown.clone();
let event_handler = move |control_event| -> ServiceControlHandlerResult {
match control_event {
ServiceControl::Stop => {
shutdown_handler.store(true, AtomicOrdering::SeqCst);
ServiceControlHandlerResult::NoError
}
ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
_ => ServiceControlHandlerResult::NotImplemented,
}
};
// Register the SCM control handler before reporting StartPending.
let status_handle = service_control_handler::register(service_name(), event_handler)?;
// Tell SCM that initialization has begun but the service is not ready yet.
status_handle.set_service_status(ServiceStatus {
service_type: ServiceType::OWN_PROCESS,
current_state: ServiceState::StartPending,
controls_accepted: ServiceControlAccept::empty(),
exit_code: ServiceExitCode::Win32(0),
checkpoint: 1,
wait_hint: Duration::from_secs(10),
process_id: None,
})?;
// The service owns its own Tokio runtime because SCM entry is synchronous.
let runtime = Runtime::new().expect("Failed to create Windows service runtime");
let _log_handle = runtime.block_on(async {
match initialize_node_logging().await {
Ok(handle) => Some(handle),
Err(err) => {
eprintln!("Failed to initialize logging: {err}");
None
}
}
});
install_panic_cleanup();
let service_state = Arc::new(RwLock::new(ServiceWaitState::WaitingForUnlock));
let service_state_for_pipe = service_state.clone();
let shutdown_for_pipe = shutdown.clone();
let (unlock_tx, mut unlock_rx) = mpsc::unbounded_channel::<String>();
let unlocked_node_task: Arc<StdMutex<Option<JoinHandle<()>>>> = Arc::new(StdMutex::new(None));
let unlocked_node_task_for_loop = unlocked_node_task.clone();
runtime.block_on(async {
// Run only the wallet-independent startup work before accepting unlock requests.
prepare_pre_wallet_startup().await;
// The named pipe stays alive while the service waits for a valid wallet key.
tokio::spawn(async move {
run_unlock_pipe_server(service_state_for_pipe, shutdown_for_pipe, unlock_tx).await;
});
});
info!(
"Windows service {} for {} is running and waiting for wallet unlock on {}.",
service_name(),
block_extension_and_paths().0,
pipe_name()
);
// At this point the service is healthy, but still locked until the helper submits a key.
status_handle.set_service_status(ServiceStatus {
service_type: ServiceType::OWN_PROCESS,
current_state: ServiceState::Running,
controls_accepted: ServiceControlAccept::STOP,
exit_code: ServiceExitCode::Win32(0),
checkpoint: 0,
wait_hint: Duration::default(),
process_id: None,
})?;
let shutdown_for_service = shutdown.clone();
runtime.block_on(async move {
loop {
tokio::select! {
maybe_wallet_key = unlock_rx.recv() => {
if let Some(wallet_key) = maybe_wallet_key {
// Once the wallet key is accepted, the shared unlocked-node
// runtime is launched inside the service process itself.
let handle = tokio::spawn(async move {
if let Err(err) = run_unlocked_node(wallet_key, false).await {
error!("Unlocked Windows service node failed during startup: {err}");
}
});
if let Ok(mut task_slot) = unlocked_node_task_for_loop.lock() {
// Keep the handle so service shutdown can abort the node task cleanly.
*task_slot = Some(handle);
}
}
}
_ = crate::sleep(Duration::from_millis(500)) => {
if shutdown_for_service.load(AtomicOrdering::SeqCst) {
break;
}
}
}
}
});
runtime.block_on(async {
// Publish the stopping state so pipe status checks see shutdown in progress.
let mut state = service_state.write().await;
*state = ServiceWaitState::Stopping;
});
warn!("Windows service received stop request.");
status_handle.set_service_status(ServiceStatus {
service_type: ServiceType::OWN_PROCESS,
current_state: ServiceState::StopPending,
controls_accepted: ServiceControlAccept::empty(),
exit_code: ServiceExitCode::Win32(0),
checkpoint: 1,
wait_hint: Duration::from_secs(10),
process_id: None,
})?;
// Stop the unlocked node task before the runtime is torn down.
if let Ok(mut task_slot) = unlocked_node_task.lock() {
if let Some(handle) = task_slot.take() {
handle.abort();
}
}
// Shutting down the runtime releases the remaining async work so the service
// process can exit cleanly and the binary can be rebuilt or uninstalled.
runtime.shutdown_timeout(StdDuration::from_secs(2));
info!("Windows service {} stopped.", service_name());
status_handle.set_service_status(ServiceStatus {
service_type: ServiceType::OWN_PROCESS,
current_state: ServiceState::Stopped,
controls_accepted: ServiceControlAccept::empty(),
exit_code: ServiceExitCode::Win32(0),
checkpoint: 0,
wait_hint: Duration::default(),
process_id: None,
})?;
Ok(())
}
#[cfg(not(windows))]
pub fn try_run_as_windows_service() -> Result<bool, Box<dyn std::error::Error>> {
Ok(false)
}
#[cfg(not(windows))]
pub fn handle_windows_service_command() -> Result<bool, Box<dyn std::error::Error>> {
Ok(false)
}