panama-osd read the wrong brightnessctl field, showing the hardware max instead of a percentage on any backlight device. panama-doctor called three sibling scripts by bare name with nothing on PATH, making three health checks permanently and falsely report broken; its repair actions also reused the short probe timeout, so a slow-but- successful restart was reported as failed. panama-wifi-qr left the cleartext passphrase temp file behind on its failure path (the RETURN trap doesn't fire on exit), and its nmcli parsing broke on connection names containing a colon or backslash -- verified against a real NetworkManager profile. panama-power-profile's set command always returned success regardless of whether the write actually took. panama-keyring's daemon-origin check picked whichever gnome-keyring-daemon process happened to enumerate first in /proc, defeating the exact dual-daemon scenario it exists to detect; it now resolves the PID that actually owns the Secret Service D-Bus name. gnf aborted before running a firmware update whenever the metadata was already current (a non-error exit under set -e), and its flatpak update lacked the -y its own docs promise. Claude-Session: https://claude.ai/code/session_01E6TJUAh41HaP25MVHWkhRZ
189 lines
6.5 KiB
Python
Executable File
189 lines
6.5 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
|
|
"""The login keyring's lock state, and a way to unlock it.
|
|
|
|
Why this exists
|
|
---------------
|
|
GNOME unlocks the login keyring at sign-in through pam_gnome_keyring, and so
|
|
does this desktop -- the PAM stack is GDM's and it works. What GNOME also has,
|
|
and a bare Hyprland session does not, is anywhere to SEE that it failed.
|
|
|
|
It does fail, rarely. gnome-keyring-daemon can crash (an upstream abort in
|
|
service_method_open_session, seen once here), and when it does, D-Bus activates
|
|
a replacement. That replacement never received the login password, so the login
|
|
keyring comes back LOCKED in the middle of a session that unlocked it correctly
|
|
at login. Everything that stores a secret then starts failing in ways that do
|
|
not mention keyrings at all: a mail client that will not authenticate, a git
|
|
push that cannot find its key, an integration that reports "not configured".
|
|
|
|
So this reports the state plainly and offers the one action that fixes it.
|
|
|
|
Unlocking prompts
|
|
-----------------
|
|
`unlock` asks the Secret Service to unlock, which raises the gcr password
|
|
dialog. That is deliberate: the password is not ours to store or handle, and it
|
|
never passes through this script. The dialog is the same one GNOME shows.
|
|
|
|
Note that a locked keyring makes a NON-INTERACTIVE caller appear to hang -- it
|
|
is not hung, it is waiting for a dialog nobody is looking at. That is worth
|
|
knowing before debugging one for an hour.
|
|
|
|
Usage:
|
|
panama-keyring status -> {"available", "locked", "collections", "daemon"}
|
|
panama-keyring unlock -> raises the password prompt; prints the new state
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
|
|
|
|
def secrets_name_owner_pid():
|
|
"""PID currently owning the org.freedesktop.secrets D-Bus name, if any.
|
|
|
|
This is the only reliable way to identify which daemon actually answers
|
|
Secret Service calls right now.
|
|
"""
|
|
try:
|
|
import gi
|
|
|
|
gi.require_version("Gio", "2.0")
|
|
from gi.repository import Gio, GLib
|
|
|
|
bus = Gio.bus_get_sync(Gio.BusType.SESSION, None)
|
|
result = bus.call_sync(
|
|
"org.freedesktop.DBus",
|
|
"/org/freedesktop/DBus",
|
|
"org.freedesktop.DBus",
|
|
"GetConnectionUnixProcessID",
|
|
GLib.Variant("(s)", ("org.freedesktop.secrets",)),
|
|
GLib.VariantType("(u)"),
|
|
Gio.DBusCallFlags.NONE,
|
|
-1,
|
|
None,
|
|
)
|
|
return result.unpack()[0]
|
|
except Exception: # noqa: BLE001 - no name owner is a legitimate state
|
|
return None
|
|
|
|
|
|
def any_keyring_daemon_running():
|
|
try:
|
|
for pid in os.listdir("/proc"):
|
|
if not pid.isdigit():
|
|
continue
|
|
try:
|
|
with open(f"/proc/{pid}/cmdline", "rb") as handle:
|
|
cmdline = handle.read().decode("utf-8", "replace")
|
|
except OSError:
|
|
continue
|
|
if "gnome-keyring-daemon" in cmdline:
|
|
return True
|
|
except OSError:
|
|
pass
|
|
return False
|
|
|
|
|
|
def daemon_origin():
|
|
"""Whether the running secrets daemon came from PAM or from D-Bus activation.
|
|
|
|
A D-Bus-activated daemon is the signature of the crash-and-replace case
|
|
above: it is the one that cannot have the login password. PAM's daemon lives
|
|
outside the app slice, so the cgroup tells the two apart.
|
|
|
|
A machine can have two gnome-keyring-daemon processes at once -- a
|
|
lingering PAM one alongside its D-Bus-activated replacement -- so which
|
|
process this reports on matters: it must be the one that actually owns
|
|
org.freedesktop.secrets right now, not merely the first one /proc happens
|
|
to enumerate.
|
|
"""
|
|
owner_pid = secrets_name_owner_pid()
|
|
if owner_pid is None:
|
|
return "unknown" if any_keyring_daemon_running() else "none"
|
|
|
|
try:
|
|
with open(f"/proc/{owner_pid}/cmdline", "rb") as handle:
|
|
cmdline = handle.read().decode("utf-8", "replace")
|
|
except OSError:
|
|
return "unknown"
|
|
if "gnome-keyring-daemon" not in cmdline:
|
|
return "unknown"
|
|
|
|
try:
|
|
with open(f"/proc/{owner_pid}/cgroup", "r") as handle:
|
|
cgroup = handle.read()
|
|
except OSError:
|
|
return "unknown"
|
|
if re.search(r"dbus-.*org\.freedesktop\.secrets", cgroup):
|
|
return "dbus"
|
|
return "pam"
|
|
|
|
|
|
def load_service():
|
|
import gi
|
|
|
|
gi.require_version("Secret", "1")
|
|
from gi.repository import Secret
|
|
|
|
return Secret, Secret.Service.get_sync(Secret.ServiceFlags.LOAD_COLLECTIONS, None)
|
|
|
|
|
|
def report(service, Secret):
|
|
collections = [
|
|
{"label": c.get_label(), "locked": c.get_locked()}
|
|
for c in service.get_collections()
|
|
]
|
|
# The login keyring is the one that matters; the others are per-application
|
|
# stores that manage their own unlocking.
|
|
login = next((c for c in collections if c["label"] == "Login"), None)
|
|
return {
|
|
"available": True,
|
|
"locked": bool(login["locked"]) if login else False,
|
|
"hasLogin": login is not None,
|
|
"collections": collections,
|
|
"daemon": daemon_origin(),
|
|
"error": "",
|
|
}
|
|
|
|
|
|
def main():
|
|
action = sys.argv[1] if len(sys.argv) > 1 else "status"
|
|
if action not in ("status", "unlock"):
|
|
print("usage: panama-keyring [status|unlock]", file=sys.stderr)
|
|
return 2
|
|
|
|
try:
|
|
Secret, service = load_service()
|
|
except Exception as error: # noqa: BLE001 - any failure here is "no keyring"
|
|
# No Secret Service at all is a legitimate state, not a crash: report it
|
|
# so the UI can say so instead of showing an empty card.
|
|
print(json.dumps({
|
|
"available": False, "locked": False, "hasLogin": False,
|
|
"collections": [], "daemon": daemon_origin(),
|
|
"error": f"The secret service is not answering: {error}",
|
|
}))
|
|
return 0
|
|
|
|
if action == "unlock":
|
|
login = next(
|
|
(c for c in service.get_collections() if c.get_label() == "Login"), None)
|
|
if login is not None and login.get_locked():
|
|
try:
|
|
# Blocks until the dialog is answered or dismissed.
|
|
service.unlock_sync([login], None)
|
|
except Exception as error: # noqa: BLE001
|
|
state = report(service, Secret)
|
|
state["error"] = f"The keyring was not unlocked: {error}"
|
|
print(json.dumps(state))
|
|
return 0
|
|
# The collection object caches its state; re-read it.
|
|
Secret, service = load_service()
|
|
|
|
print(json.dumps(report(service, Secret)))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|