146 lines
5.7 KiB
Python
146 lines
5.7 KiB
Python
"""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
|