Files
guru-connect/agent/src/install.rs
Mike Swanson 7602b4346a feat(agent): SPEC-018 Phase 1 managed-agent SYSTEM service host
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>
2026-06-02 13:43:01 -07:00

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(&current_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()
}