Run the managed/persistent GuruConnect agent as a LocalSystem Windows service so it is reachable at the login screen and across reboots, and so the SPEC-016 per-machine cak_ store (ACL-restricted to SYSTEM + Administrators) is finally readable in-context. Phase 1 scope (host + lifecycle only): - New agent/src/service/mod.rs: registers "GuruConnectAgent" with the SCM via the windows-service dispatcher, reports a correct lifecycle (StartPending -> Running -> StopPending -> Stopped), handles Stop/Shutdown via an AtomicBool the agent loop polls (graceful WS close), and provides install/uninstall/start (LocalSystem, AutoStart, sc-failure crash recovery). Idempotent install/uninstall. - main.rs: hidden `service-run` subcommand routes the SCM-launched process into the dispatcher; new run_managed_agent_service() runs the existing RunMode::PermanentAgent logic (resolve/enroll cak_, hold the relay) as SYSTEM. run_agent() now takes an optional SCM shutdown flag, skips the HKCU Run autostart and the tray when run as the service, and interrupts the reconnect backoff promptly on stop. An interactive launch of a managed binary now installs+starts the service and exits instead of double-running. - install.rs: a managed install (embedded config present) installs the LocalSystem service as the single autostart and removes the legacy HKCU Run entry; uninstall stops+deletes the service (idempotent). Attended/viewer installs are untouched. - Kept the SPEC-016 Phase B fail-fast guard as a harmless safety net for any non-SYSTEM invocation; updated its comment to name this service as the managed run context. Phase 2 NOT built (seams documented): session broker, per-session capture/input worker, CreateProcessAsUserW token handoff, service/worker IPC, and SERVICE_CONTROL_SESSIONCHANGE. Phase 1 enrolls/connects as SYSTEM but does not capture a desktop (a Session-0 process cannot). No service is installed/started on the dev host; that is a VM/admin integration step. fmt + clippy -D warnings + release build + 55 tests all pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
492 lines
15 KiB
Rust
492 lines
15 KiB
Rust
//! Installation and protocol handler registration
|
|
//!
|
|
//! Handles:
|
|
//! - Self-installation to Program Files (with UAC) or LocalAppData (fallback)
|
|
//! - Protocol handler registration (guruconnect://)
|
|
//! - UAC elevation with graceful fallback
|
|
|
|
use anyhow::{anyhow, Result};
|
|
use tracing::{info, warn};
|
|
|
|
#[cfg(windows)]
|
|
use windows::{
|
|
core::PCWSTR,
|
|
Win32::Foundation::HANDLE,
|
|
Win32::Security::{GetTokenInformation, TokenElevation, TOKEN_ELEVATION, TOKEN_QUERY},
|
|
Win32::System::Registry::{
|
|
RegCloseKey, RegCreateKeyExW, RegSetValueExW, HKEY, HKEY_CLASSES_ROOT, HKEY_CURRENT_USER,
|
|
KEY_WRITE, REG_OPTION_NON_VOLATILE, REG_SZ,
|
|
},
|
|
Win32::System::Threading::{GetCurrentProcess, OpenProcessToken},
|
|
Win32::UI::Shell::ShellExecuteW,
|
|
Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL,
|
|
};
|
|
|
|
#[cfg(windows)]
|
|
use std::ffi::OsStr;
|
|
#[cfg(windows)]
|
|
use std::os::windows::ffi::OsStrExt;
|
|
|
|
/// Install locations
|
|
pub const SYSTEM_INSTALL_PATH: &str = r"C:\Program Files\GuruConnect";
|
|
pub const USER_INSTALL_PATH: &str = r"GuruConnect"; // Relative to %LOCALAPPDATA%
|
|
|
|
/// Check if running with elevated privileges
|
|
#[cfg(windows)]
|
|
pub fn is_elevated() -> bool {
|
|
unsafe {
|
|
let mut token_handle = HANDLE::default();
|
|
if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token_handle).is_err() {
|
|
return false;
|
|
}
|
|
|
|
let mut elevation = TOKEN_ELEVATION::default();
|
|
let mut size = std::mem::size_of::<TOKEN_ELEVATION>() as u32;
|
|
|
|
let result = GetTokenInformation(
|
|
token_handle,
|
|
TokenElevation,
|
|
Some(&mut elevation as *mut _ as *mut _),
|
|
size,
|
|
&mut size,
|
|
);
|
|
|
|
let _ = windows::Win32::Foundation::CloseHandle(token_handle);
|
|
|
|
result.is_ok() && elevation.TokenIsElevated != 0
|
|
}
|
|
}
|
|
|
|
#[cfg(not(windows))]
|
|
pub fn is_elevated() -> bool {
|
|
unsafe { libc::geteuid() == 0 }
|
|
}
|
|
|
|
/// Get the install path based on elevation status
|
|
pub fn get_install_path(elevated: bool) -> std::path::PathBuf {
|
|
if elevated {
|
|
std::path::PathBuf::from(SYSTEM_INSTALL_PATH)
|
|
} else {
|
|
let local_app_data = std::env::var("LOCALAPPDATA").unwrap_or_else(|_| {
|
|
let home = std::env::var("USERPROFILE").unwrap_or_else(|_| ".".to_string());
|
|
format!(r"{}\AppData\Local", home)
|
|
});
|
|
std::path::PathBuf::from(local_app_data).join(USER_INSTALL_PATH)
|
|
}
|
|
}
|
|
|
|
/// Get the executable path
|
|
pub fn get_exe_path(install_path: &std::path::Path) -> std::path::PathBuf {
|
|
install_path.join("guruconnect.exe")
|
|
}
|
|
|
|
/// Attempt to elevate and re-run with install command
|
|
#[cfg(windows)]
|
|
pub fn try_elevate_and_install() -> Result<bool> {
|
|
let exe_path = std::env::current_exe()?;
|
|
let exe_path_wide: Vec<u16> = OsStr::new(exe_path.as_os_str())
|
|
.encode_wide()
|
|
.chain(std::iter::once(0))
|
|
.collect();
|
|
|
|
let verb: Vec<u16> = OsStr::new("runas")
|
|
.encode_wide()
|
|
.chain(std::iter::once(0))
|
|
.collect();
|
|
|
|
let params: Vec<u16> = OsStr::new("install --elevated")
|
|
.encode_wide()
|
|
.chain(std::iter::once(0))
|
|
.collect();
|
|
|
|
unsafe {
|
|
let result = ShellExecuteW(
|
|
None,
|
|
PCWSTR(verb.as_ptr()),
|
|
PCWSTR(exe_path_wide.as_ptr()),
|
|
PCWSTR(params.as_ptr()),
|
|
PCWSTR::null(),
|
|
SW_SHOWNORMAL,
|
|
);
|
|
|
|
// ShellExecuteW returns > 32 on success
|
|
if result.0 as usize > 32 {
|
|
info!("UAC elevation requested");
|
|
Ok(true)
|
|
} else {
|
|
warn!("UAC elevation denied or failed");
|
|
Ok(false)
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(not(windows))]
|
|
pub fn try_elevate_and_install() -> Result<bool> {
|
|
Ok(false)
|
|
}
|
|
|
|
/// Register the guruconnect:// protocol handler
|
|
#[cfg(windows)]
|
|
pub fn register_protocol_handler(elevated: bool) -> Result<()> {
|
|
let install_path = get_install_path(elevated);
|
|
let exe_path = get_exe_path(&install_path);
|
|
let exe_path_str = exe_path.to_string_lossy();
|
|
|
|
// Command to execute: "C:\...\guruconnect.exe" "launch" "%1"
|
|
let command = format!("\"{}\" launch \"%1\"", exe_path_str);
|
|
|
|
// Choose registry root based on elevation
|
|
let root_key = if elevated {
|
|
HKEY_CLASSES_ROOT
|
|
} else {
|
|
// User-level registration under Software\Classes
|
|
HKEY_CURRENT_USER
|
|
};
|
|
|
|
let base_path = if elevated {
|
|
"guruconnect"
|
|
} else {
|
|
r"Software\Classes\guruconnect"
|
|
};
|
|
|
|
unsafe {
|
|
// Create guruconnect key
|
|
let mut protocol_key = HKEY::default();
|
|
let key_path = to_wide(base_path);
|
|
let result = RegCreateKeyExW(
|
|
root_key,
|
|
PCWSTR(key_path.as_ptr()),
|
|
0,
|
|
PCWSTR::null(),
|
|
REG_OPTION_NON_VOLATILE,
|
|
KEY_WRITE,
|
|
None,
|
|
&mut protocol_key,
|
|
None,
|
|
);
|
|
if result.is_err() {
|
|
return Err(anyhow!("Failed to create protocol key: {:?}", result));
|
|
}
|
|
|
|
// Set default value (protocol description)
|
|
let description = to_wide("GuruConnect Protocol");
|
|
let result = RegSetValueExW(
|
|
protocol_key,
|
|
PCWSTR::null(),
|
|
0,
|
|
REG_SZ,
|
|
Some(&description_to_bytes(&description)),
|
|
);
|
|
if result.is_err() {
|
|
let _ = RegCloseKey(protocol_key);
|
|
return Err(anyhow!("Failed to set protocol description: {:?}", result));
|
|
}
|
|
|
|
// Set URL Protocol (empty string indicates this is a protocol handler)
|
|
let url_protocol = to_wide("URL Protocol");
|
|
let empty = to_wide("");
|
|
let result = RegSetValueExW(
|
|
protocol_key,
|
|
PCWSTR(url_protocol.as_ptr()),
|
|
0,
|
|
REG_SZ,
|
|
Some(&description_to_bytes(&empty)),
|
|
);
|
|
if result.is_err() {
|
|
let _ = RegCloseKey(protocol_key);
|
|
return Err(anyhow!("Failed to set URL Protocol: {:?}", result));
|
|
}
|
|
|
|
let _ = RegCloseKey(protocol_key);
|
|
|
|
// Create shell\open\command key
|
|
let command_path = if elevated {
|
|
r"guruconnect\shell\open\command"
|
|
} else {
|
|
r"Software\Classes\guruconnect\shell\open\command"
|
|
};
|
|
let command_key_path = to_wide(command_path);
|
|
let mut command_key = HKEY::default();
|
|
let result = RegCreateKeyExW(
|
|
root_key,
|
|
PCWSTR(command_key_path.as_ptr()),
|
|
0,
|
|
PCWSTR::null(),
|
|
REG_OPTION_NON_VOLATILE,
|
|
KEY_WRITE,
|
|
None,
|
|
&mut command_key,
|
|
None,
|
|
);
|
|
if result.is_err() {
|
|
return Err(anyhow!("Failed to create command key: {:?}", result));
|
|
}
|
|
|
|
// Set the command
|
|
let command_wide = to_wide(&command);
|
|
let result = RegSetValueExW(
|
|
command_key,
|
|
PCWSTR::null(),
|
|
0,
|
|
REG_SZ,
|
|
Some(&description_to_bytes(&command_wide)),
|
|
);
|
|
if result.is_err() {
|
|
let _ = RegCloseKey(command_key);
|
|
return Err(anyhow!("Failed to set command: {:?}", result));
|
|
}
|
|
|
|
let _ = RegCloseKey(command_key);
|
|
}
|
|
|
|
info!("Protocol handler registered: guruconnect://");
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(not(windows))]
|
|
pub fn register_protocol_handler(_elevated: bool) -> Result<()> {
|
|
warn!("Protocol handler registration not supported on this platform");
|
|
Ok(())
|
|
}
|
|
|
|
/// Install the application
|
|
pub fn install(force_user_install: bool) -> Result<()> {
|
|
let elevated = is_elevated();
|
|
|
|
// If not elevated and not forcing user install, try to elevate
|
|
if !elevated && !force_user_install {
|
|
info!("Attempting UAC elevation for system-wide install...");
|
|
match try_elevate_and_install() {
|
|
Ok(true) => {
|
|
// Elevation was requested, exit this instance
|
|
// The elevated instance will continue the install
|
|
info!("Elevated process started, exiting current instance");
|
|
std::process::exit(0);
|
|
}
|
|
Ok(false) => {
|
|
info!("UAC denied, falling back to user install");
|
|
}
|
|
Err(e) => {
|
|
warn!("Elevation failed: {}, falling back to user install", e);
|
|
}
|
|
}
|
|
}
|
|
|
|
let install_path = get_install_path(elevated);
|
|
let exe_path = get_exe_path(&install_path);
|
|
|
|
info!("Installing to: {}", install_path.display());
|
|
|
|
// Create install directory
|
|
std::fs::create_dir_all(&install_path)?;
|
|
|
|
// Copy ourselves to install location
|
|
let current_exe = std::env::current_exe()?;
|
|
if current_exe != exe_path {
|
|
std::fs::copy(¤t_exe, &exe_path)?;
|
|
info!("Copied executable to: {}", exe_path.display());
|
|
}
|
|
|
|
// Register protocol handler
|
|
register_protocol_handler(elevated)?;
|
|
|
|
// SPEC-018: a MANAGED install (embedded config => persistent agent) installs
|
|
// the LocalSystem service as its single autostart and removes the per-user
|
|
// HKCU\…\Run entry. Attended (support-code) and viewer installs are untouched:
|
|
// they have no embedded config and continue to use the HKCU Run / protocol
|
|
// handler paths exactly as before.
|
|
#[cfg(windows)]
|
|
{
|
|
if crate::config::Config::has_embedded_config() {
|
|
install_managed_service(&exe_path)?;
|
|
}
|
|
}
|
|
|
|
info!("Installation complete!");
|
|
if elevated {
|
|
info!("Installed system-wide to: {}", install_path.display());
|
|
} else {
|
|
info!("Installed for current user to: {}", install_path.display());
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// SPEC-018: install the managed agent as a LocalSystem service and swap out the
|
|
/// legacy per-user `HKCU\…\Run` autostart so the service is the single managed
|
|
/// autostart (no double-run).
|
|
///
|
|
/// Installing a LocalSystem service requires Administrator. If the SCM rejects the
|
|
/// create (not elevated), we surface the error rather than silently leaving the
|
|
/// machine with no managed autostart — a managed deployment is expected to run the
|
|
/// install elevated. The HKCU Run entry is removed best-effort regardless.
|
|
#[cfg(windows)]
|
|
pub fn install_managed_service(exe_path: &std::path::Path) -> Result<()> {
|
|
info!("Managed install: registering LocalSystem service (SPEC-018)");
|
|
|
|
crate::service::install_service(exe_path)
|
|
.map_err(|e| anyhow!("failed to install the managed agent service: {e:#}"))?;
|
|
|
|
// Start the service now so the agent comes up immediately on first install
|
|
// rather than only on the next boot. Best-effort: the service is auto-start, so
|
|
// a transient start failure still self-heals on reboot.
|
|
if let Err(e) = crate::service::start_service() {
|
|
warn!(
|
|
"managed service installed but did not start now ({e:#}); \
|
|
it is auto-start and will run on next boot"
|
|
);
|
|
}
|
|
|
|
// Remove the legacy per-user autostart so the agent does not also launch in the
|
|
// user's session (which would double-run alongside the service).
|
|
if let Err(e) = crate::startup::remove_from_startup() {
|
|
warn!(
|
|
"managed service installed, but failed to remove the legacy HKCU Run \
|
|
autostart (harmless if it was never present): {}",
|
|
e
|
|
);
|
|
} else {
|
|
info!("removed legacy HKCU Run autostart (service is now the managed autostart)");
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// SPEC-018: remove the managed agent service and any legacy HKCU Run autostart.
|
|
/// Idempotent — succeeds if neither is present.
|
|
#[cfg(windows)]
|
|
pub fn uninstall_managed_service() -> Result<()> {
|
|
info!("Managed uninstall: removing LocalSystem service (SPEC-018)");
|
|
|
|
// Best-effort removal of the legacy autostart first (cheap, no SCM).
|
|
if let Err(e) = crate::startup::remove_from_startup() {
|
|
warn!(
|
|
"failed to remove legacy HKCU Run autostart during uninstall: {}",
|
|
e
|
|
);
|
|
}
|
|
|
|
crate::service::uninstall_service()
|
|
.map_err(|e| anyhow!("failed to uninstall the managed agent service: {e:#}"))
|
|
}
|
|
|
|
/// Check if the guruconnect:// protocol handler is registered
|
|
#[cfg(windows)]
|
|
pub fn is_protocol_handler_registered() -> bool {
|
|
use windows::Win32::System::Registry::{
|
|
RegCloseKey, RegOpenKeyExW, HKEY_CLASSES_ROOT, HKEY_CURRENT_USER, KEY_READ,
|
|
};
|
|
|
|
unsafe {
|
|
// Check system-wide registration (HKCR\guruconnect)
|
|
let mut key = HKEY::default();
|
|
let key_path = to_wide("guruconnect");
|
|
if RegOpenKeyExW(
|
|
HKEY_CLASSES_ROOT,
|
|
PCWSTR(key_path.as_ptr()),
|
|
0,
|
|
KEY_READ,
|
|
&mut key,
|
|
)
|
|
.is_ok()
|
|
{
|
|
let _ = RegCloseKey(key);
|
|
return true;
|
|
}
|
|
|
|
// Check user-level registration (HKCU\Software\Classes\guruconnect)
|
|
let key_path = to_wide(r"Software\Classes\guruconnect");
|
|
if RegOpenKeyExW(
|
|
HKEY_CURRENT_USER,
|
|
PCWSTR(key_path.as_ptr()),
|
|
0,
|
|
KEY_READ,
|
|
&mut key,
|
|
)
|
|
.is_ok()
|
|
{
|
|
let _ = RegCloseKey(key);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
false
|
|
}
|
|
|
|
#[cfg(not(windows))]
|
|
pub fn is_protocol_handler_registered() -> bool {
|
|
// On non-Windows, assume not registered (or check ~/.local/share/applications)
|
|
false
|
|
}
|
|
|
|
/// Parse a guruconnect:// URL and extract session parameters
|
|
pub fn parse_protocol_url(url_str: &str) -> Result<(String, String, Option<String>)> {
|
|
// Expected formats:
|
|
// guruconnect://view/SESSION_ID
|
|
// guruconnect://view/SESSION_ID?token=API_KEY
|
|
// guruconnect://connect/SESSION_ID?server=wss://...&token=API_KEY
|
|
//
|
|
// Note: In URL parsing, "view" becomes the host, SESSION_ID is the path
|
|
|
|
let url = url::Url::parse(url_str).map_err(|e| anyhow!("Invalid URL: {}", e))?;
|
|
|
|
if url.scheme() != "guruconnect" {
|
|
return Err(anyhow!("Invalid scheme: expected guruconnect://"));
|
|
}
|
|
|
|
// The "action" (view/connect) is parsed as the host
|
|
let action = url
|
|
.host_str()
|
|
.ok_or_else(|| anyhow!("Missing action in URL"))?;
|
|
|
|
// The session ID is the first path segment
|
|
let path = url.path().trim_start_matches('/');
|
|
info!("URL path: '{}', host: '{:?}'", path, url.host_str());
|
|
let session_id = if path.is_empty() {
|
|
return Err(anyhow!(
|
|
"Invalid URL: Missing session ID (path was empty, full URL: {})",
|
|
url_str
|
|
));
|
|
} else {
|
|
path.split('/').next().unwrap_or("").to_string()
|
|
};
|
|
|
|
if session_id.is_empty() {
|
|
return Err(anyhow!("Missing session ID"));
|
|
}
|
|
|
|
// Extract query parameters
|
|
let mut server = None;
|
|
let mut token = None;
|
|
|
|
for (key, value) in url.query_pairs() {
|
|
match key.as_ref() {
|
|
"server" => server = Some(value.to_string()),
|
|
"token" | "api_key" => token = Some(value.to_string()),
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
// Default server if not specified
|
|
let server = server.unwrap_or_else(|| "wss://connect.azcomputerguru.com/ws/viewer".to_string());
|
|
|
|
match action {
|
|
"view" | "connect" => Ok((server, session_id, token)),
|
|
_ => Err(anyhow!("Unknown action: {}", action)),
|
|
}
|
|
}
|
|
|
|
// Helper functions for Windows registry operations
|
|
#[cfg(windows)]
|
|
fn to_wide(s: &str) -> Vec<u16> {
|
|
OsStr::new(s)
|
|
.encode_wide()
|
|
.chain(std::iter::once(0))
|
|
.collect()
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn description_to_bytes(wide: &[u16]) -> Vec<u8> {
|
|
wide.iter().flat_map(|w| w.to_le_bytes()).collect()
|
|
}
|