Skip to content

feat: windows service for uac+login support#373

Open
jonstelly wants to merge 10 commits intofeschber:mainfrom
jonstelly:pr/windows-service
Open

feat: windows service for uac+login support#373
jonstelly wants to merge 10 commits intofeschber:mainfrom
jonstelly:pr/windows-service

Conversation

@jonstelly
Copy link
Contributor

@jonstelly jonstelly commented Feb 4, 2026

Support for installing and running lan-mouse as a windows service. This allows lan-mouse input on UAC elevation prompts and the login/unlock screens.

I'm flipping this PR out of draft mode but I imagine it will need some feedback and comments/direction for things to clean up before it's 100% ready to merge.

General architecture

  • lan-mouse can still be run directly on windows without installing/running the service. This will have the current limitation around not being able to control UAC elevation prompts or login/unlock screens, but still works exactly as it does today
  • lan-mouse can optionally run as service - The service is a sort of watchdog/orchestrator that will spawn individual lan-mouse daemon processes inside/attached-to user sessions with elevated permissions

Running/Testing

1a. build lan-mouse from the jonstelly:pr/windows-service branch
OR
1b. Download build from my fork
2. Copy ./target/debug files or extract the release zip file to a directory - c:\ProgramData\lan-mouse is a good option, this is where the config file and certificate will live
3. Launch an elevated terminal from the directory
4. Run lan-mouse install to install and start the windows service - If you already had a lan-mouse config file in your user's local-app-data lan-mouse directory, then the config and certificate will be copied into c:\ProgramData\lan-mouse with the install command, if not then the service will fail to start, you'll need to create the config.toml file in this directory now and then start the service

At this point, lan-mouse should be working in your normal windows desktop session, login screen, unlock screen, or UAC elevation prompts.

Looking for feedback

  • I added an "install" and "uninstall" CLI command to install the service in windows. In my head it feels like it would be nice to have 1 "install" command for each OS. For linux the install command could write the systemd user service file then enable it... For macos I understand there's launchd but I've never even looked into it. But the general thinking was that this could be the same command for install to make documenting process easy, even though on each OS, install means something different
  • For lan-mouse running as a service, it shouldn't attempt to load the config from a user's directory, so I adopted the recommended c:\ProgramData\lan-mouse directory for storing config and certificate. The install command above will look for a config.toml and lan-mouse.pem file in the user's lan-mouse config directory, if present in user path and not yet in ProgramData, then the install command will copy those files. It's not my favorite pattern, but it seems mostly intuitive, but open for any discussion here
  • Meta+l to lock a remote windows machine doesn't work. That's a secure action sequence, so I added special handling for windows emulation to catch Meta+l and lock the desktop. I tested this code both running lan-mouse as the windows service, and running it from non-elevated terminal, it seems to work in both cases
  • Added a handful of comments for different parts of code, specifically curious for input on the file logging, anything else
  • This is probably the most rust code I've put together in 1 PR so welcome any feedback, I know there's already some cleanup I want to do, but happy to hear about anything else

/// to the current input desktop.
fn send_input_safe(input: INPUT) {
unsafe {
loop {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was this loop to handle some flakiness of SendInput? Seems like could get stuck if SendInput fails and never succeeds from simple retry? Should it be added back, or switched to something to try X times before logging error and giving up?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From win32 doc:

The function returns the number of events that it successfully inserted into the keyboard or mouse input stream. If the function returns zero, the input was already blocked by another thread. To get extended error information, call GetLastError.

So I did this to make sure the event gets submitted eventually.

} else {
log::info!("WTSDisconnectSession succeeded (session {})", session_id);
}
}
Copy link
Contributor Author

@jonstelly jonstelly Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have tested this on windows running lan-mouse as a service and as normal non-service process, locking workstation works in both cases

@feschber
Copy link
Owner

feschber commented Feb 13, 2026

So for the other points:
The commands install, uninstall and win-svc make sense to me, we need a separate command to run the daemon rather than spawn it.
Maybe in the future we could have install, uninstall and start / stop for all platforms in some way. But I'm fine with keeping it windows only for now.

For lan-mouse running as a service, it shouldn't attempt to load the config from a user's directory, so I adopted the recommended c:\ProgramData\lan-mouse directory for storing config and certificate. The install command above will look for a config.toml and lan-mouse.pem file in the user's lan-mouse config directory, if present in user path and not yet in ProgramData, then the install command will copy those files. It's not my favorite pattern, but it seems mostly intuitive, but open for any discussion here

This seems fine to me. I don't see any security implication as long as the pem file does not get copied when already present.

let app_data =
env::var("LOCALAPPDATA").unwrap_or(format!("{}/.config", env::var("USERPROFILE")?));
format!("{app_data}\\lan-mouse\\")
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The os config nesting here has a bit of a smell to me, happy to hear any suggestions for cleanup

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the #[cfg(not(windows))] part even necessary? What platform would that be?

return;
}
}
}
Copy link
Contributor Author

@jonstelly jonstelly Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think env_logger has log-file rotation? So calling out that the log file would just continue to grow, probably run into size problems at some point.

Should we:

  • remove the file logging completely (means it's impossible to see the log of what's happening in the lan-mouse windows service, or the daemon sub-process
  • make it configurable somehow? It's possible to set environment variables for a windows service via regedit, or if they're set as system-level environment variables (I believe system-level environment variables would need a system reboot to take effect)
  • truncate the log on startup - if lan-mouse ran for a long time it could still end up in a large log file, but it would at least reset/truncate the file on reboot or restart
  • something else?

@@ -0,0 +1,137 @@
#!/usr/bin/env pwsh
<#
.SYNOPSIS
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was really just a helper to script the build, service stop, file copy, service start... mainly to simplify my development workflow of testing the windows service on a remote windows machine so I could do all that in a 1-liner at an elevated terminal... I'm happy to remove this if you'd rather it not in the repo.

My usage was:

./run.ps1 -Build -Service

@jonstelly jonstelly marked this pull request as ready for review February 13, 2026 18:21
Copilot AI review requested due to automatic review settings February 13, 2026 18:21
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds Windows service support to lan-mouse, enabling input control on UAC elevation prompts and login/unlock screens. The architecture uses a service orchestrator running as SYSTEM in Session 0 that spawns and manages session daemons in active user sessions with elevated privileges.

Changes:

  • Implements Windows service infrastructure with session management and automatic daemon spawning/recovery
  • Adds install/uninstall/status CLI commands for Windows service management
  • Introduces desktop switching for input injection on Secure Desktop (UAC prompts)
  • Separates config paths: user-specific (%LOCALAPPDATA%) vs service-wide (C:\ProgramData)

Reviewed changes

Copilot reviewed 11 out of 12 changed files in this pull request and generated 16 comments.

Show a summary per file
File Description
src/windows_service.rs Core Windows service implementation with session monitoring and daemon lifecycle management
src/windows.rs Service installation, uninstall, config/cert migration, and firewall rule setup
src/main.rs Logging initialization for service mode and new command routing
src/config.rs Service-aware path resolution and new Windows-specific commands
src/service.rs Optional shutdown channel support for graceful service termination
src/lib.rs Windows service flag and module exports
input-emulation/src/windows.rs Desktop switching for UAC support and Win+L interception
run.ps1 Development script for building and testing the service
Cargo.toml, Cargo.lock Added windows-service dependency

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Err(e) => {
// Check if the service doesn't exist (error code 1060)
let hresult = e.code();
if hresult.0 == -2147024908i32 {
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The magic number -2147024908 for ERROR_SERVICE_DOES_NOT_EXIST should be defined as a named constant. This is the same magic number used in windows_service.rs and would benefit from being defined in a shared location.

Copilot uses AI. Check for mistakes.

// Copy config to ProgramData
let program_data = std::path::Path::new("C:\\ProgramData\\lan-mouse");
let _ = std::fs::create_dir_all(program_data);
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error from create_dir_all is being silently ignored with let _ = ..., but if directory creation fails, the subsequent file operations will also fail without clear error messages. Consider logging a warning if create_dir_all fails so users can diagnose permission issues.

Suggested change
let _ = std::fs::create_dir_all(program_data);
if let Err(e) = std::fs::create_dir_all(program_data) {
log::warn!(
"Failed to create ProgramData directory {:?}: {}",
program_data,
e
);
}

Copilot uses AI. Check for mistakes.
event = self.resolver.event() => self.handle_resolver_event(event),
r = signal::ctrl_c() => break r.expect("failed to wait for CTRL+C"),
r = signal::ctrl_c(), if shutdown.is_none() => break r.expect("failed to wait for CTRL+C"),
_ = async { shutdown.as_mut().unwrap().recv().await }, if shutdown.is_some() => {
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tokio::select! branch that handles shutdown.recv() calls unwrap() on shutdown.as_mut(), which could panic if shutdown is None. However, the guard condition "if shutdown.is_some()" should prevent this. While correct, this pattern is complex and error-prone. Consider refactoring to avoid the unwrap() by restructuring the select branches or using a cleaner pattern with if-let or match.

Suggested change
_ = async { shutdown.as_mut().unwrap().recv().await }, if shutdown.is_some() => {
_ = async {
if let Some(shutdown) = shutdown.as_mut() {
let _ = shutdown.recv().await;
}
}, if shutdown.is_some() => {

Copilot uses AI. Check for mistakes.
Comment on lines +46 to +52
let config = match config::Config::new() {
Ok(c) => c,
Err(e) => {
eprintln!("Error loading config: {e}");
process::exit(1);
}
};
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Config loading happens before logging is initialized, so if Config::new() fails, the error is only printed to eprintln and won't be logged to the file. For the Windows service case, this means config errors won't appear in the service log file. Consider initializing basic logging before config loading, or handling config errors specially for the service case.

Copilot uses AI. Check for mistakes.
Comment on lines +25 to +26
let scm = OpenSCManagerW(None, None, SC_MANAGER_ALL_ACCESS)
.map_err(|e| format!("Failed to open SCM: {}", e))?;
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The SCM handle returned by OpenSCManagerW is not being closed before the function returns. This creates a resource leak. The handle should be closed using CloseServiceHandle (or rely on the windows crate's RAII if the handle type implements Drop, but it should be verified). The same issue exists with the service handle obtained from CreateServiceW on line 39 and OpenServiceW.

Copilot uses AI. Check for mistakes.
Comment on lines +95 to +99
if key == KEY_L && state == 1 && self.meta_pressed {
log::info!("Win+L detected, locking workstation");
lock_workstation();
return Ok(());
}
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When Win+L is intercepted and lock_workstation() is called, the function returns early without calling key_event() or spawning the repeat task. This means the L key press is never sent to the system. However, this also means the Meta key state remains pressed in the emulation layer, which could cause issues if the user releases Meta after locking. Consider whether the Meta key should also be released or if this behavior is intentional.

Copilot uses AI. Check for mistakes.
// Build command line with explicit config path pointing to ProgramData
// This ensures the session daemon uses the machine-wide config regardless of
// which user token (or winlogon token) is used to spawn it
let config_path = r"C:\ProgramData\lan-mouse\config.toml";
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hardcoded path should use the PROGRAMDATA environment variable for consistency and to support non-standard Windows installations.

Suggested change
let config_path = r"C:\ProgramData\lan-mouse\config.toml";
let program_data = std::env::var("PROGRAMDATA")
.unwrap_or_else(|_| String::from(r"C:\ProgramData"));
let config_path = format!(r"{}\lan-mouse\config.toml", program_data);

Copilot uses AI. Check for mistakes.
.join("lan-mouse")
.join("config.toml");
if src_config.exists() {
let _ = std::fs::copy(src_config, dst_config);
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error from std::fs::copy is being silently ignored with let _ = ..., making it difficult for users to diagnose why their config isn't being copied. Consider logging a warning similar to how the certificate copy is handled (lines 82-85) so users know if the operation failed.

Copilot uses AI. Check for mistakes.
Comment on lines +206 to +207
let scm = OpenSCManagerW(None, None, SC_MANAGER_ALL_ACCESS)
.map_err(|e| format!("Failed to open SCM: {}", e))?;
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The SCM and service handles are not being closed. Similar to the install function, these handles should be properly closed before returning to avoid resource leaks.

Copilot uses AI. Check for mistakes.
*
* 1. Monitor active console session via WTSGetActiveConsoleSessionId()
* 2. Spawn `lan-mouse daemon` in user session using CreateProcessAsUser()
* 3. Acquire appropriate token (WTSQueryUserToken or winlogon token)
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment references "WTSQueryUserToken or winlogon token" but the implementation only uses the winlogon token approach. The comment should be updated to reflect that only the winlogon.exe token is used, as WTSQueryUserToken was found to be insufficient for accessing the Secure Desktop.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants