diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..907d905 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,40 @@ +## Which issue does this PR close? + + + +- Closes #. + +## Rationale for this change + + + +## What changes are included in this PR? + + + +## Are these changes tested? + + + +## Are there any user-facing changes? + + + + diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 3040c2b..c4fbbc3 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -96,6 +96,7 @@ version = "0.1.0" dependencies = [ "dirs 5.0.1", "dotenvy", + "libc", "log", "reqwest", "serde", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c530d91..3d0753d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -30,3 +30,4 @@ tauri-plugin-fs = "2.0.0-rc" tauri-plugin-log = "2.0.0-rc" tauri-plugin-shell = "2" dirs = "5.0" +libc = "0.2" diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 8959dce..fc3a9bd 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -2,14 +2,32 @@ pub mod commands; pub mod utils; +pub mod sudo; + +use sudo::{SudoCache, fast_sudo, clear_sudo_cache, direct_privilege_escalation, check_sudo_privileges}; +use tauri::Manager; fn main() { dotenvy::dotenv().ok(); tauri::Builder::default() + .setup(|app| { + app.manage(SudoCache::new()); + + let cache = app.state::(); + let cache_clone = cache.inner().clone(); + + std::thread::spawn(move || { + loop { + std::thread::sleep(std::time::Duration::from_secs(300)); + cache_clone.clear_expired(15); + } + }); + + Ok(()) + }) .invoke_handler(tauri::generate_handler![ commands::shell::run_shell, - commands::shell::run_sudo_command, commands::shell::get_current_dir, commands::shell::list_directory_contents, commands::shell::change_directory, @@ -17,8 +35,12 @@ fn main() { commands::api_key::save_api_key, commands::api_key::get_api_key, commands::api_key::validate_api_key, - commands::api_key::delete_api_key + commands::api_key::delete_api_key, + fast_sudo, + clear_sudo_cache, + direct_privilege_escalation, + check_sudo_privileges ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); -} +} \ No newline at end of file diff --git a/src-tauri/src/sudo.rs b/src-tauri/src/sudo.rs new file mode 100644 index 0000000..f255e63 --- /dev/null +++ b/src-tauri/src/sudo.rs @@ -0,0 +1,297 @@ +// src-tauri/src/sudo.rs +use std::collections::HashMap; +use std::process::{Command, Stdio}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; +use std::io::Write; +use tauri::State; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone)] +pub struct AuthToken { + timestamp: Instant, + user_id: u32, +} + +#[derive(Default)] +pub struct SudoCache { + pub tokens: Arc>>, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct SudoRequest { + pub command: String, + pub args: Vec, + pub password: Option, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct SudoResponse { + pub success: bool, + pub output: String, + pub error: Option, + pub cached: bool, + pub needs_password: bool, +} + +impl SudoCache { + pub fn new() -> Self { + Self { + tokens: Arc::new(Mutex::new(HashMap::new())), + } + } + + pub fn is_authenticated(&self, user_id: u32, timeout_minutes: u64) -> bool { + if let Ok(tokens) = self.tokens.lock() { + if let Some(token) = tokens.get(&user_id) { + return token.timestamp.elapsed() < Duration::from_secs(timeout_minutes * 60); + } + } + false + } + + pub fn authenticate(&self, user_id: u32) { + if let Ok(mut tokens) = self.tokens.lock() { + tokens.insert(user_id, AuthToken { + timestamp: Instant::now(), + user_id, + }); + } + } + + pub fn clear_expired(&self, timeout_minutes: u64) { + if let Ok(mut tokens) = self.tokens.lock() { + let timeout = Duration::from_secs(timeout_minutes * 60); + tokens.retain(|_, token| token.timestamp.elapsed() < timeout); + } + } + + pub fn clear_all(&self) { + if let Ok(mut tokens) = self.tokens.lock() { + tokens.clear(); + } + } +} + +fn get_current_user_id() -> Result> { + unsafe { + Ok(libc::getuid()) + } +} + +fn verify_password(password: &str) -> Result> { + let mut child = Command::new("sudo") + .args(&["-S", "-v"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn()?; + + if let Some(stdin) = child.stdin.as_mut() { + writeln!(stdin, "{}", password)?; + } + + let output = child.wait_with_output()?; + Ok(output.status.success()) +} + +async fn execute_sudo_command( + command: &str, + args: &[String], + use_cached: bool, +) -> Result { + let mut cmd_args = Vec::new(); + + if use_cached { + cmd_args.push("-n".to_string()); // Non-interactive mode for cached auth + } + + cmd_args.push(command.to_string()); + cmd_args.extend_from_slice(args); + + let output = Command::new("sudo") + .args(&cmd_args) + .output() + .map_err(|e| format!("Failed to execute command: {}", e))?; + + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + + if output.status.success() { + Ok(SudoResponse { + success: true, + output: stdout, + error: None, + cached: use_cached, + needs_password: false, + }) + } else { + // Check if it failed because of missing authentication + if use_cached && stderr.contains("no password entry") { + Ok(SudoResponse { + success: false, + output: String::new(), + error: Some("Authentication required".to_string()), + cached: false, + needs_password: true, + }) + } else { + Ok(SudoResponse { + success: false, + output: stdout, + error: Some(stderr), + cached: use_cached, + needs_password: false, + }) + } + } +} + +#[tauri::command] +pub async fn fast_sudo( + request: SudoRequest, + cache: State<'_, SudoCache>, +) -> Result { + let user_id = get_current_user_id().map_err(|e| e.to_string())?; + let timeout_minutes = 15; // 15 minute timeout + + // Clear expired tokens + cache.clear_expired(timeout_minutes); + + let mut needs_auth = true; + let mut use_cached = false; + + // Check if already authenticated + if cache.is_authenticated(user_id, timeout_minutes) { + use_cached = true; + needs_auth = false; + } + + // If we have cached auth, try to use it first + if use_cached { + match execute_sudo_command(&request.command, &request.args, true).await { + Ok(response) => { + if response.success { + return Ok(response); + } else if response.needs_password { + // Cache expired, need to re-authenticate + needs_auth = true; + use_cached = false; + } else { + return Ok(response); + } + } + Err(e) => return Err(e), + } + } + + // If not cached and no password provided, request password + if needs_auth && request.password.is_none() { + return Ok(SudoResponse { + success: false, + output: String::new(), + error: Some("Password required".to_string()), + cached: false, + needs_password: true, + }); + } + + // Verify password if needed + if needs_auth { + if let Some(ref password) = request.password { + match verify_password(password) { + Ok(true) => { + cache.authenticate(user_id); + use_cached = false; // First time auth, not cached + } + Ok(false) => { + return Ok(SudoResponse { + success: false, + output: String::new(), + error: Some("Invalid password".to_string()), + cached: false, + needs_password: true, + }); + } + Err(e) => { + return Ok(SudoResponse { + success: false, + output: String::new(), + error: Some(format!("Authentication error: {}", e)), + cached: false, + needs_password: false, + }); + } + } + } + } + + // Execute the command + execute_sudo_command(&request.command, &request.args, false).await +} + +#[tauri::command] +pub async fn clear_sudo_cache(cache: State<'_, SudoCache>) -> Result<(), String> { + cache.clear_all(); + + // Also clear system sudo cache + let _ = Command::new("sudo") + .args(&["-k"]) + .output(); + + Ok(()) +} + +#[tauri::command] +pub async fn check_sudo_privileges() -> Result { + let output = Command::new("sudo") + .args(&["-n", "true"]) + .output() + .map_err(|e| format!("Failed to check privileges: {}", e))?; + + Ok(output.status.success()) +} + +#[tauri::command] +pub async fn direct_privilege_escalation( + command: String, + args: Vec, +) -> Result { + // Direct privilege escalation without sudo + + // For now, fall back to regular sudo + let output = Command::new("sudo") + .arg(&command) + .args(&args) + .output() + .map_err(|e| format!("Failed to execute command: {}", e))?; + + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + + Ok(SudoResponse { + success: output.status.success(), + output: stdout, + error: if stderr.is_empty() { None } else { Some(stderr) }, + cached: false, + needs_password: false, + }) +} + +// Utility function to parse sudo commands +pub fn parse_sudo_command(input: &str) -> Option<(String, Vec)> { + let parts: Vec<&str> = input.trim().split_whitespace().collect(); + + if parts.is_empty() || parts[0] != "sudo" { + return None; + } + + if parts.len() < 2 { + return None; + } + + let command = parts[1].to_string(); + let args = parts[2..].iter().map(|s| s.to_string()).collect(); + + Some((command, args)) +} \ No newline at end of file diff --git a/src/components/PasswordDialog.tsx b/src/components/PasswordDialog.tsx index 479876f..c0f5303 100644 --- a/src/components/PasswordDialog.tsx +++ b/src/components/PasswordDialog.tsx @@ -33,6 +33,12 @@ const PasswordDialog: React.FC = ({ isOpen, onClose, onSubm onSubmit(password); }; + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Escape' && !isSubmitting) { + onClose(); + } + }; + if (!isOpen) return null; return ( @@ -44,7 +50,7 @@ const PasswordDialog: React.FC = ({ isOpen, onClose, onSubm -

Administrator Privileges Required

+

⚡ Fast Sudo Authentication

@@ -54,7 +60,7 @@ const PasswordDialog: React.FC = ({ isOpen, onClose, onSubm {commandText} -

+
= ({ isOpen, onClose, onSubm disabled={isSubmitting} />

- Your password will not be stored and is only used to execute this command. + Password will be cached for 15 minutes for faster subsequent commands.

@@ -94,7 +100,9 @@ const PasswordDialog: React.FC = ({ isOpen, onClose, onSubm Authenticating... ) : ( - 'Submit' + <> + ⚡ Authenticate + )} @@ -104,4 +112,4 @@ const PasswordDialog: React.FC = ({ isOpen, onClose, onSubm ); }; -export default PasswordDialog; +export default PasswordDialog; \ No newline at end of file diff --git a/src/hooks/useFastSudo.ts b/src/hooks/useFastSudo.ts new file mode 100644 index 0000000..da69924 --- /dev/null +++ b/src/hooks/useFastSudo.ts @@ -0,0 +1,84 @@ +// src/hooks/useFastSudo.ts +import { invoke } from '@tauri-apps/api/core'; +import { useState, useCallback } from 'react'; + +interface SudoRequest { + command: string; + args: string[]; + password?: string; +} + +interface SudoResponse { + success: boolean; + output: string; + error?: string; + cached: boolean; + needs_password: boolean; +} + +export const useFastSudo = () => { + const [isLoading, setIsLoading] = useState(false); + const [needsPassword, setNeedsPassword] = useState(false); + + const executeSudo = useCallback(async (request: SudoRequest): Promise => { + setIsLoading(true); + try { + const response = await invoke('fast_sudo', { request }); + + setNeedsPassword(response.needs_password); + + return response; + } catch (error) { + console.error('Fast sudo error:', error); + return { + success: false, + output: '', + error: `Failed to execute sudo command: ${error}`, + cached: false, + needs_password: false, + }; + } finally { + setIsLoading(false); + } + }, []); + + const clearCache = useCallback(async (): Promise => { + try { + await invoke('clear_sudo_cache'); + setNeedsPassword(false); + } catch (error) { + console.error('Failed to clear sudo cache:', error); + } + }, []); + + const checkPrivileges = useCallback(async (): Promise => { + try { + return await invoke('check_sudo_privileges'); + } catch (error) { + console.error('Failed to check sudo privileges:', error); + return false; + } + }, []); + + return { + executeSudo, + clearCache, + checkPrivileges, + isLoading, + needsPassword, + }; +}; + +export const parseSudoCommand = (input: string): { command: string; args: string[] } | null => { + const trimmed = input.trim(); + const parts = trimmed.split(/\s+/); + + if (parts.length < 2 || parts[0] !== 'sudo') { + return null; + } + + return { + command: parts[1], + args: parts.slice(2), + }; +}; \ No newline at end of file