"""Ejecutar ordenes como root de forma controlada. El problema que resuelve. Newelle convierte `sudo` en `sudo -S` (leer la contraseña de la entrada estandar), pero en el panel de voz no hay una entrada donde teclearla, asi que se queda colgado esperando una contraseña que no llega. Y las acciones marcadas para confirmar no tenian dialogo: solo decian "confirme antes" y no ejecutaban nada. Como se resuelve, y las decisiones de seguridad detras: - La contraseña NUNCA se escribe en disco. Vive en memoria de este proceso solo si el usuario activa el permiso de sesion, y se pasa a `sudo -S` por stdin. - El permiso de sesion CADUCA: a los 15 minutos de la ultima orden se olvida sola, y tambien al cerrar el panel. No es un "root para siempre". - CADA orden con root muestra su comando exacto en un dialogo antes de correr. El permiso de sesion solo ahorra re-teclear la contraseña, nunca salta el ver y aprobar lo que se va a ejecutar. La voz no puede autoaprobar root. Alternativa descartada: apoyarse solo en el timestamp de sudo (`sudo -v`). Es mas limpio en teoria —lo gestiona el sistema— pero a traves de flatpak-spawn el tty cambia entre llamadas y el cacheo no es fiable. Mantener la contraseña en memoria del proceso, con caducidad y sin tocar disco, funciona siempre y el usuario opta a ello de forma explicita. """ import re import subprocess import time # A los 15 minutos sin usarla, la contraseña en memoria se olvida. Igual que el # valor por defecto de sudo, para no sorprender. CADUCIDAD = 15 * 60 # Detecta una invocacion real de sudo: al principio del comando o justo tras un # separador (;, |, &, &&, ||, salto de linea), no la palabra "sudo" dicha como # argumento ("echo sudo no cuenta"). Errar aqui solo pediria una contraseña de # mas, no es peligroso, pero molesta. _SUDO = re.compile(r"(?:^|[;&|\n]|&&|\|\|)\s*sudo(?:\s|$)") def necesita_root(comando: str) -> bool: """True si el comando lanza algo con sudo.""" return bool(comando and _SUDO.search(comando)) class GestorRoot: """Guarda —o no— la contraseña de sesion, y ejecuta con ella. Una instancia por panel. Al cerrar el panel se llama a olvida(). """ def __init__(self, prefijo_host=None): # prefijo para salir del sandbox; se pasa como argumento para poder # probar esto sin flatpak self._prefijo = list(prefijo_host or []) self._password = None self._ultimo_uso = 0.0 # -------------------------------------------------------------- sesion def sesion_activa(self) -> bool: """Hay contraseña recordada y aun no ha caducado.""" if self._password is None: return False if time.monotonic() - self._ultimo_uso > CADUCIDAD: self.olvida() return False return True def olvida(self): """Borra la contraseña de memoria. Se llama al cerrar el panel y al caducar. Se sobreescribe antes de soltarla, por si acaso.""" if self._password is not None: self._password = "\x00" * len(self._password) self._password = None self._ultimo_uso = 0.0 def recuerda(self, password: str): self._password = password self._ultimo_uso = time.monotonic() # ------------------------------------------------------------ ejecutar def valida(self, password: str) -> bool: """Comprueba la contraseña con `sudo -S -v`, sin ejecutar nada mas. -k borra cualquier timestamp previo para que la comprobacion sea de verdad; -v solo actualiza credenciales. Si la contraseña es correcta devuelve True; nunca lanza ningun comando del usuario. """ try: r = subprocess.run( self._prefijo + ["sudo", "-S", "-k", "-p", "", "-v"], input=password + "\n", capture_output=True, text=True, timeout=10) return r.returncode == 0 except (OSError, subprocess.SubprocessError): return False def ejecuta(self, comando: str, password: str = None, timeout: int = 30): """Ejecuta el comando dando la contraseña a los sudo que lleve. password: si se pasa, se usa esa vez; si no, la de la sesion. La contraseña se pasa por stdin a `sudo -S`. No se registra en ningun sitio. Devuelve (salida, codigo). codigo -1 = no habia contraseña disponible. """ clave = password if password is not None else self._password if clave is None: return "No hay contraseña disponible para root.", -1 # que todos los sudo del comando lean de stdin sin imprimir el prompt preparado = re.sub(r"(^|[;&|]|\s)sudo(\s)", r"\1sudo -S -p '' \2", comando) try: r = subprocess.run( self._prefijo + ["bash", "-lc", preparado], input=clave + "\n", capture_output=True, text=True, timeout=timeout) salida = (r.stdout or r.stderr or "").strip() # sudo escribe su prompt/errores en stderr aunque -p sea vacio; # se limpia lo que es ruido de sudo, no del comando salida = "\n".join( l for l in salida.splitlines() if l.strip() and not l.startswith(("[sudo]", "Sorry, try again"))) self._ultimo_uso = time.monotonic() return salida, r.returncode except subprocess.TimeoutExpired: return "La orden con root ha tardado demasiado.", 124 except OSError as e: return f"No se pudo ejecutar: {e}", 1 finally: # no dejar la copia local viva mas de lo necesario clave = "\x00" * len(clave)