...
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
"""Graphical password prompts for server-relayed `sudo` commands.
|
||||
|
||||
The pet has no terminal. When the server relays something like
|
||||
`sudo apt update`, sudo tries to read a password from a tty, finds none (or
|
||||
finds the terminal the pet was launched from, which you're not looking at),
|
||||
and the command fails with no way to answer it.
|
||||
|
||||
sudo's own answer to this is SUDO_ASKPASS: with `-A`, it runs a helper
|
||||
program and reads the password from the helper's stdout instead of a tty.
|
||||
Any GUI prompt that prints what was typed works, so this module finds a real
|
||||
askpass binary if one is installed and otherwise generates a one-line wrapper
|
||||
around zenity/kdialog, which every desktop has one of.
|
||||
|
||||
Worth being clear about what this changes: the prompt is a *feature*, not
|
||||
just plumbing. Server-relayed commands already run as your desktop user (see
|
||||
server_client.run_local_command); this lets them ask to run as root, and the
|
||||
dialog is the only thing standing between "Bolt decided to run sudo" and it
|
||||
happening. Leave SUDO_ASKPASS_PROMPT on, and read the dialogs.
|
||||
|
||||
The parts that decide *what* to run are pure functions so they're tested
|
||||
without a display, a password, or a working sudo.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
from . import config
|
||||
|
||||
# Real askpass binaries, in preference order. These are purpose-built for
|
||||
# this (they grab the keyboard, hide the input, and don't leave the password
|
||||
# in a process argument), so they win over a generated wrapper.
|
||||
_KNOWN_HELPERS = (
|
||||
"/usr/bin/ssh-askpass",
|
||||
"/usr/lib/ssh/ssh-askpass",
|
||||
"/usr/lib/openssh/gnome-ssh-askpass3",
|
||||
"/usr/lib/openssh/gnome-ssh-askpass",
|
||||
"/usr/libexec/openssh/ssh-askpass",
|
||||
"/usr/bin/ksshaskpass",
|
||||
"/usr/bin/lxqt-openssh-askpass",
|
||||
)
|
||||
|
||||
# Dialog tools we can wrap when no askpass binary exists. Each must print the
|
||||
# typed password to stdout and nothing else.
|
||||
_WRAPPABLE = {
|
||||
"zenity": '{tool} --password --title="Bolt" --text="$1" 2>/dev/null',
|
||||
"kdialog": '{tool} --password "$1" --title "Bolt" 2>/dev/null',
|
||||
}
|
||||
|
||||
# `sudo` at the start of the command or right after a shell separator, not
|
||||
# already carrying a flag. Deliberately conservative: a `sudo` inside a
|
||||
# quoted string or a heredoc is left alone, because rewriting it could change
|
||||
# what the command means.
|
||||
_SUDO = re.compile(r"(^|[;&|]\s*|\n\s*)(sudo)(\s+)(?!-)")
|
||||
|
||||
|
||||
def add_askpass_flag(command: str) -> str:
|
||||
"""Insert `-A` after each bare `sudo`, so it prompts through the helper
|
||||
instead of a tty. Commands that already pass a flag (`sudo -n`, `sudo -A`,
|
||||
`sudo -u bob`) are left exactly as they are — the caller was explicit."""
|
||||
return _SUDO.sub(r"\1\2 -A\3", command or "")
|
||||
|
||||
|
||||
def needs_password_prompt(command: str) -> bool:
|
||||
"""True if *command* has a `sudo` that might sit waiting on a dialog.
|
||||
Used to give those commands a longer timeout — 30 seconds is fine for a
|
||||
shell command and nowhere near enough for a human to notice a window,
|
||||
read it, and type a password."""
|
||||
return bool(_SUDO.search(command or ""))
|
||||
|
||||
|
||||
def helper_script(tool_path: str) -> str:
|
||||
"""The wrapper script for a dialog *tool_path*. sudo passes its prompt
|
||||
("[sudo] password for maji:") as $1, which is worth showing — it names
|
||||
the user the password is for."""
|
||||
name = Path(tool_path).name
|
||||
body = _WRAPPABLE[name].format(tool=tool_path)
|
||||
return f"#!/bin/sh\n# Generated by bolt-pet. Prints the typed password on stdout for sudo -A.\n{body}\n"
|
||||
|
||||
|
||||
def _default_cache_dir() -> Path:
|
||||
base = os.environ.get("XDG_CACHE_HOME") or (Path.home() / ".cache")
|
||||
return Path(base) / "bolt-pet"
|
||||
|
||||
|
||||
def find_helper(
|
||||
configured: str = None,
|
||||
is_executable: Callable[[str], bool] = None,
|
||||
which: Callable[[str], Optional[str]] = None,
|
||||
cache_dir: Path = None,
|
||||
write: bool = True,
|
||||
) -> Optional[str]:
|
||||
"""Path to an askpass helper, or None if the desktop has nothing we can
|
||||
use. Order: whatever SUDO_ASKPASS_HELPER names, then a real askpass
|
||||
binary, then a generated wrapper around zenity/kdialog.
|
||||
|
||||
The lookups are injectable so the resolution order is testable on a box
|
||||
with a different set of these installed than yours."""
|
||||
configured = config.SUDO_ASKPASS_HELPER if configured is None else configured
|
||||
is_executable = is_executable or (lambda path: os.path.isfile(path) and os.access(path, os.X_OK))
|
||||
which = which or shutil.which
|
||||
|
||||
if configured:
|
||||
return configured if is_executable(configured) else None
|
||||
|
||||
for candidate in _KNOWN_HELPERS:
|
||||
if is_executable(candidate):
|
||||
return candidate
|
||||
|
||||
for tool in _WRAPPABLE:
|
||||
tool_path = which(tool)
|
||||
if not tool_path:
|
||||
continue
|
||||
if not write:
|
||||
return tool_path
|
||||
return _write_wrapper(tool_path, cache_dir or _default_cache_dir())
|
||||
return None
|
||||
|
||||
|
||||
def _write_wrapper(tool_path: str, cache_dir: Path) -> Optional[str]:
|
||||
"""Drop the wrapper script somewhere sudo can execute it. Mode 0700: it
|
||||
isn't secret, but it's a thing that pops up a password box, so nobody
|
||||
else on the machine gets to edit it."""
|
||||
try:
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
script = cache_dir / "askpass.sh"
|
||||
source = helper_script(tool_path)
|
||||
if not script.exists() or script.read_text(encoding="utf-8") != source:
|
||||
script.write_text(source, encoding="utf-8")
|
||||
script.chmod(stat.S_IRWXU)
|
||||
return str(script)
|
||||
except Exception:
|
||||
return None # no prompt is better than a crashed command relay
|
||||
|
||||
|
||||
def environment(helper: str, base: dict = None) -> dict:
|
||||
"""The subprocess environment with SUDO_ASKPASS pointed at *helper*."""
|
||||
env = dict(os.environ if base is None else base)
|
||||
env["SUDO_ASKPASS"] = helper
|
||||
return env
|
||||
Reference in New Issue
Block a user