Files
leaguerecorder/record-daemon/src/lqp/auth.rs
T

341 lines
10 KiB
Rust

//! LQP authentication via League Client lockfile.
//!
//! The League Client writes a lockfile on startup containing connection credentials.
use std::fs;
use std::path::PathBuf;
use std::time::Duration;
use tracing::{debug, trace, warn};
use crate::error::{LqpError, Result};
/// League Client lockfile location (relative to League Client install).
pub const LOCKFILE_NAME: &str = "lockfile";
/// Default League Client paths on Linux (via Lutris/Wine).
pub const LINUX_CLIENT_PATHS: &[&str] = &[
// Lutris default path
"$HOME/Games/League of Legends/LeagueClient",
// Wine prefix
"$HOME/.wine/drive_c/Riot Games/League of Legends/LeagueClient",
];
/// Default League Client paths on Windows.
pub const WINDOWS_CLIENT_PATHS: &[&str] = &[
"C:\\Riot Games\\League of Legends\\lockfile",
"D:\\Riot Games\\League of Legends\\lockfile",
];
/// Credentials extracted from the League Client lockfile.
#[derive(Debug, Clone)]
pub struct LockfileCredentials {
/// Process name (usually "LeagueClient").
pub process_name: String,
/// Process ID.
pub pid: u32,
/// Port for the LQP API.
pub port: u16,
/// Password for authentication.
pub password: String,
/// Protocol (usually "https").
pub protocol: String,
}
impl LockfileCredentials {
/// Parse lockfile contents into credentials.
///
/// Lockfile format: `LeagueClient:PID:PORT:PASSWORD:PROTOCOL`
pub fn parse(contents: &str) -> Result<Self> {
let parts: Vec<&str> = contents.trim().split(':').collect();
if parts.len() != 5 {
return Err(LqpError::LockfileParseError(format!(
"Expected 5 fields, got {}",
parts.len()
))
.into());
}
let process_name = parts[0].to_string();
let pid = parts[1]
.parse::<u32>()
.map_err(|e| LqpError::LockfileParseError(format!("Invalid PID: {}", e)))?;
let port = parts[2]
.parse::<u16>()
.map_err(|e| LqpError::LockfileParseError(format!("Invalid port: {}", e)))?;
let password = parts[3].to_string();
let protocol = parts[4].to_string();
Ok(Self {
process_name,
pid,
port,
password,
protocol,
})
}
/// Get the base URL for the LQP REST API.
pub fn base_url(&self) -> String {
format!("{}://127.0.0.1:{}", self.protocol, self.port)
}
/// Get the WebSocket URL for the LQP WebSocket API.
pub fn ws_url(&self) -> String {
format!("wss://127.0.0.1:{}", self.port)
}
/// Get the Basic Auth header value.
pub fn auth_header(&self) -> String {
let credentials = format!("riot:{}", self.password);
let encoded =
base64::Engine::encode(&base64::engine::general_purpose::STANDARD, credentials);
format!("Basic {}", encoded)
}
}
/// Watcher for League Client lockfile.
///
/// Monitors for the League Client starting and stopping by watching
/// for the lockfile to appear/disappear.
pub struct LockfileWatcher {
/// Possible lockfile paths to check.
paths: Vec<PathBuf>,
/// Current credentials if client is running.
current: Option<LockfileCredentials>,
}
impl LockfileWatcher {
/// Create a new lockfile watcher with default paths.
pub fn new() -> Self {
let paths = Self::discover_lockfile_paths();
Self {
paths,
current: None,
}
}
/// Create a watcher with a specific lockfile path.
pub fn with_path(path: PathBuf) -> Self {
Self {
paths: vec![path],
current: None,
}
}
/// Discover possible lockfile paths based on OS.
fn discover_lockfile_paths() -> Vec<PathBuf> {
let mut paths = Vec::new();
#[cfg(target_os = "linux")]
{
// Check for Wine/Lutris installations
if let Some(home) = std::env::var_os("HOME") {
let home_path = PathBuf::from(&home);
// Lutris default
let lutris_path = home_path
.join("Games")
.join("League of Legends")
.join("lockfile");
paths.push(lutris_path);
// Wine prefix
let wine_path = home_path
.join(".wine")
.join("drive_c")
.join("Riot Games")
.join("League of Legends")
.join("lockfile");
paths.push(wine_path);
// Proton (Steam)
let proton_path = home_path
.join(".local")
.join("share")
.join("Steam")
.join("steamapps")
.join("compatdata")
.join("League of Legends")
.join("pfx")
.join("drive_c")
.join("Riot Games")
.join("League of Legends")
.join("lockfile");
paths.push(proton_path);
}
}
#[cfg(target_os = "windows")]
{
// Default Windows paths
paths.push(PathBuf::from("C:\\Riot Games\\League of Legends\\lockfile"));
paths.push(PathBuf::from("D:\\Riot Games\\League of Legends\\lockfile"));
// Check Program Files
paths.push(PathBuf::from(
"C:\\Program Files\\Riot Games\\League of Legends\\lockfile",
));
paths.push(PathBuf::from(
"C:\\Program Files (x86)\\Riot Games\\League of Legends\\lockfile",
));
}
paths
}
/// Check if the League Client is currently running.
pub fn is_client_running(&self) -> bool {
self.current.is_some()
}
/// Get current credentials if available.
pub fn credentials(&self) -> Option<&LockfileCredentials> {
self.current.as_ref()
}
/// Check for lockfile and update credentials.
///
/// Returns:
/// - `Some(true)` if client just started
/// - `Some(false)` if client just stopped
/// - `None` if no change
pub fn check(&mut self) -> Result<Option<bool>> {
// Try to find and read lockfile
let found = self.find_lockfile()?;
match (found, &self.current) {
(Some(creds), None) => {
// Client started
debug!(
"League Client detected (PID: {}, Port: {})",
creds.pid, creds.port
);
self.current = Some(creds);
Ok(Some(true))
}
(Some(new_creds), Some(old_creds)) => {
// Client still running, check if restarted
if new_creds.pid != old_creds.pid {
debug!(
"League Client restarted (PID: {} -> {})",
old_creds.pid, new_creds.pid
);
self.current = Some(new_creds);
// Don't report as change, just update
Ok(None)
} else {
// No change
Ok(None)
}
}
(None, Some(_)) => {
// Client stopped
debug!("League Client stopped");
self.current = None;
Ok(Some(false))
}
(None, None) => {
// Still not running
Ok(None)
}
}
}
/// Find and read the lockfile.
fn find_lockfile(&self) -> Result<Option<LockfileCredentials>> {
for path in &self.paths {
trace!("Checking lockfile path: {:?}", path);
if path.exists() {
match fs::read_to_string(path) {
Ok(contents) => match LockfileCredentials::parse(&contents) {
Ok(creds) => return Ok(Some(creds)),
Err(e) => {
warn!("Failed to parse lockfile at {:?}: {}", path, e);
continue;
}
},
Err(e) => {
trace!("Failed to read lockfile at {:?}: {}", path, e);
continue;
}
}
}
}
Ok(None)
}
/// Wait for the League Client to start.
///
/// Blocks until the client is detected or timeout is reached.
pub async fn wait_for_client(&mut self, timeout: Duration) -> Result<LockfileCredentials> {
let start = std::time::Instant::now();
loop {
if let Some(creds) = self.find_lockfile()? {
self.current = Some(creds.clone());
return Ok(creds);
}
if start.elapsed() >= timeout {
return Err(LqpError::Timeout.into());
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
}
}
impl Default for LockfileWatcher {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_lockfile() {
let contents = "LeagueClient:12345:52436:abc123:https";
let creds = LockfileCredentials::parse(contents).unwrap();
assert_eq!(creds.process_name, "LeagueClient");
assert_eq!(creds.pid, 12345);
assert_eq!(creds.port, 52436);
assert_eq!(creds.password, "abc123");
assert_eq!(creds.protocol, "https");
}
#[test]
fn test_base_url() {
let creds = LockfileCredentials {
process_name: "LeagueClient".to_string(),
pid: 12345,
port: 52436,
password: "abc123".to_string(),
protocol: "https".to_string(),
};
assert_eq!(creds.base_url(), "https://127.0.0.1:52436");
}
#[test]
fn test_auth_header() {
let creds = LockfileCredentials {
process_name: "LeagueClient".to_string(),
pid: 12345,
port: 52436,
password: "abc123".to_string(),
protocol: "https".to_string(),
};
// base64("riot:abc123") = "cmlvdDphYmMxMjM="
assert!(creds.auth_header().contains("Basic "));
}
}