feat: windows service for uac+login support#373
feat: windows service for uac+login support#373jonstelly wants to merge 10 commits intofeschber:mainfrom
Conversation
| /// to the current input desktop. | ||
| fn send_input_safe(input: INPUT) { | ||
| unsafe { | ||
| loop { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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); | ||
| } | ||
| } |
There was a problem hiding this comment.
Have tested this on windows running lan-mouse as a service and as normal non-service process, locking workstation works in both cases
|
So for the other points:
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\\") | ||
| } |
There was a problem hiding this comment.
The os config nesting here has a bit of a smell to me, happy to hear any suggestions for cleanup
There was a problem hiding this comment.
Is the #[cfg(not(windows))] part even necessary? What platform would that be?
| return; | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
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 | |||
There was a problem hiding this comment.
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 -ServiceThere was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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.
|
|
||
| // Copy config to ProgramData | ||
| let program_data = std::path::Path::new("C:\\ProgramData\\lan-mouse"); | ||
| let _ = std::fs::create_dir_all(program_data); |
There was a problem hiding this comment.
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.
| 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 | |
| ); | |
| } |
| 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() => { |
There was a problem hiding this comment.
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.
| _ = 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() => { |
| let config = match config::Config::new() { | ||
| Ok(c) => c, | ||
| Err(e) => { | ||
| eprintln!("Error loading config: {e}"); | ||
| process::exit(1); | ||
| } | ||
| }; |
There was a problem hiding this comment.
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.
| let scm = OpenSCManagerW(None, None, SC_MANAGER_ALL_ACCESS) | ||
| .map_err(|e| format!("Failed to open SCM: {}", e))?; |
There was a problem hiding this comment.
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.
| if key == KEY_L && state == 1 && self.meta_pressed { | ||
| log::info!("Win+L detected, locking workstation"); | ||
| lock_workstation(); | ||
| return Ok(()); | ||
| } |
There was a problem hiding this comment.
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.
| // 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"; |
There was a problem hiding this comment.
The hardcoded path should use the PROGRAMDATA environment variable for consistency and to support non-standard Windows installations.
| 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); |
| .join("lan-mouse") | ||
| .join("config.toml"); | ||
| if src_config.exists() { | ||
| let _ = std::fs::copy(src_config, dst_config); |
There was a problem hiding this comment.
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.
| let scm = OpenSCManagerW(None, None, SC_MANAGER_ALL_ACCESS) | ||
| .map_err(|e| format!("Failed to open SCM: {}", e))?; |
There was a problem hiding this comment.
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.
| * | ||
| * 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) |
There was a problem hiding this comment.
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.
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
Running/Testing
1a. build lan-mouse from the jonstelly:pr/windows-service branch
OR
1b. Download build from my fork
2. Copy
./target/debugfiles or extract the release zip file to a directory -c:\ProgramData\lan-mouseis a good option, this is where the config file and certificate will live3. Launch an elevated terminal from the directory
4. Run
lan-mouse installto 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 intoc:\ProgramData\lan-mousewith 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 serviceAt this point, lan-mouse should be working in your normal windows desktop session, login screen, unlock screen, or UAC elevation prompts.
Looking for feedback
c:\ProgramData\lan-mousedirectory 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