Harden Panama doctor probes
This commit is contained in:
@@ -54,20 +54,22 @@ class DoctorConfig:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def command_env(self) -> dict[str, str]:
|
def command_env(self) -> dict[str, str]:
|
||||||
environment = dict(os.environ)
|
environment = {
|
||||||
environment["PATH"] = self.path
|
"PATH": self.path,
|
||||||
environment["HOME"] = str(self.home)
|
"HOME": str(self.home),
|
||||||
environment["XDG_CONFIG_HOME"] = str(self.config_home)
|
"XDG_CONFIG_HOME": str(self.config_home),
|
||||||
environment["XDG_STATE_HOME"] = str(self.state_home)
|
"XDG_STATE_HOME": str(self.state_home),
|
||||||
environment["XDG_RUNTIME_DIR"] = str(self.runtime_dir)
|
"XDG_RUNTIME_DIR": str(self.runtime_dir),
|
||||||
environment.pop("PANAMA_HOME_ASSISTANT_URL", None)
|
}
|
||||||
environment.pop("PANAMA_HOME_ASSISTANT_TOKEN", None)
|
for name in PROBE_ENVIRONMENT_KEYS:
|
||||||
|
if value := os.environ.get(name):
|
||||||
|
environment[name] = value
|
||||||
return environment
|
return environment
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class CommandResult:
|
class CommandResult:
|
||||||
state: Literal["ok", "missing", "timeout", "failed"]
|
state: Literal["ok", "missing", "timeout", "failed", "unavailable"]
|
||||||
stdout: str = ""
|
stdout: str = ""
|
||||||
|
|
||||||
|
|
||||||
@@ -91,6 +93,52 @@ SYSTEMCTL_COMMANDS = {
|
|||||||
PROCESS_NAMES = ("quickshell", "vicinae", "hyprpaper", "hypridle")
|
PROCESS_NAMES = ("quickshell", "vicinae", "hyprpaper", "hypridle")
|
||||||
VERSION_PATTERN = re.compile(r"\b\d+(?:\.\d+){0,3}(?:[-+._][A-Za-z0-9._-]+)?\b")
|
VERSION_PATTERN = re.compile(r"\b\d+(?:\.\d+){0,3}(?:[-+._][A-Za-z0-9._-]+)?\b")
|
||||||
REVISION_PATTERN = re.compile(r"\b[0-9a-f]{7,40}\b", re.IGNORECASE)
|
REVISION_PATTERN = re.compile(r"\b[0-9a-f]{7,40}\b", re.IGNORECASE)
|
||||||
|
PROBE_ENVIRONMENT_KEYS = (
|
||||||
|
"LANG",
|
||||||
|
"LC_ALL",
|
||||||
|
"LC_CTYPE",
|
||||||
|
"TZ",
|
||||||
|
"DBUS_SESSION_BUS_ADDRESS",
|
||||||
|
"WAYLAND_DISPLAY",
|
||||||
|
"DISPLAY",
|
||||||
|
"XAUTHORITY",
|
||||||
|
"PANAMA_DOCTOR_FIXTURE_STOPPED",
|
||||||
|
"PANAMA_DOCTOR_FIXTURE_PROCESSES",
|
||||||
|
"PANAMA_DOCTOR_FIXTURE_BUS",
|
||||||
|
"PANAMA_DOCTOR_FIXTURE_QS",
|
||||||
|
"PANAMA_DOCTOR_FIXTURE_QS_VERSION",
|
||||||
|
"PANAMA_DOCTOR_FIXTURE_BLUEBUBBLES",
|
||||||
|
"PANAMA_DOCTOR_FIXTURE_CALENDAR",
|
||||||
|
"PANAMA_DOCTOR_FIXTURE_BRIGHTNESS",
|
||||||
|
"PANAMA_DOCTOR_FIXTURE_CAFFEINE",
|
||||||
|
)
|
||||||
|
CHECK_TITLES = {
|
||||||
|
"desktop.hyprland": "Hyprland",
|
||||||
|
"desktop.quickshell": "Quickshell",
|
||||||
|
"desktop.notifications": "Notifications",
|
||||||
|
"desktop.portals": "Desktop portals",
|
||||||
|
"desktop.hyprpaper": "Hyprpaper",
|
||||||
|
"desktop.hypridle": "Hypridle",
|
||||||
|
"desktop.vicinae": "Vicinae",
|
||||||
|
"input.pipewire": "PipeWire",
|
||||||
|
"input.clipboard": "Clipboard",
|
||||||
|
"input.wallpaper": "Wallpaper",
|
||||||
|
"input.capture": "Capture",
|
||||||
|
"input.ocr": "OCR",
|
||||||
|
"input.brightness": "External monitor brightness",
|
||||||
|
"integration.nextcloud": "Nextcloud",
|
||||||
|
"integration.rustdesk": "RustDesk",
|
||||||
|
"integration.kdeconnect": "KDE Connect",
|
||||||
|
"integration.bluebubbles": "BlueBubbles",
|
||||||
|
"integration.home-assistant": "Home Assistant",
|
||||||
|
"integration.calendar": "Calendar",
|
||||||
|
"panama.runtime-links": "Panama runtime links",
|
||||||
|
"panama.vicinae-commands": "Panama commands",
|
||||||
|
"panama.selected-terminal": "Selected terminal",
|
||||||
|
"panama.selected-launcher": "Selected launcher",
|
||||||
|
"panama.processes": "Panama processes",
|
||||||
|
"panama.caffeine": "Caffeine inhibitor",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def environment_path(name: str, default: Path) -> Path:
|
def environment_path(name: str, default: Path) -> Path:
|
||||||
@@ -119,6 +167,8 @@ def run_command(command: tuple[str, ...], config: DoctorConfig, cwd: Path | None
|
|||||||
return CommandResult("missing")
|
return CommandResult("missing")
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
return CommandResult("timeout")
|
return CommandResult("timeout")
|
||||||
|
except OSError:
|
||||||
|
return CommandResult("unavailable")
|
||||||
if completed.returncode != 0:
|
if completed.returncode != 0:
|
||||||
return CommandResult("failed")
|
return CommandResult("failed")
|
||||||
return CommandResult("ok", completed.stdout)
|
return CommandResult("ok", completed.stdout)
|
||||||
@@ -156,7 +206,7 @@ def service_check(check_id: str, title: str, service: str, config: DoctorConfig,
|
|||||||
result = run_command(SYSTEMCTL_COMMANDS[service], config)
|
result = run_command(SYSTEMCTL_COMMANDS[service], config)
|
||||||
if result.state == "ok":
|
if result.state == "ok":
|
||||||
return Check(check_id, group_for(check_id), title, "ok", "Service is active.")
|
return Check(check_id, group_for(check_id), title, "ok", "Service is active.")
|
||||||
if result.state == "missing":
|
if result.state in {"missing", "unavailable"}:
|
||||||
return Check(check_id, group_for(check_id), title, "error", "Required system service probe is unavailable.")
|
return Check(check_id, group_for(check_id), title, "error", "Required system service probe is unavailable.")
|
||||||
return Check(check_id, group_for(check_id), title, "warning", "Service is not active.", action)
|
return Check(check_id, group_for(check_id), title, "warning", "Service is not active.", action)
|
||||||
|
|
||||||
@@ -315,7 +365,10 @@ def check_processes(config: DoctorConfig) -> Check:
|
|||||||
for name in PROCESS_NAMES:
|
for name in PROCESS_NAMES:
|
||||||
result = run_command(("pgrep", "-u", str(os.getuid()), "-x", name), config)
|
result = run_command(("pgrep", "-u", str(os.getuid()), "-x", name), config)
|
||||||
if result.state == "ok":
|
if result.state == "ok":
|
||||||
counts.append(sum(line.isdecimal() for line in result.stdout.splitlines()))
|
pids = result.stdout.splitlines()
|
||||||
|
if not pids or any(not pid.isdecimal() for pid in pids):
|
||||||
|
return Check("panama.processes", "panama-tools", "Panama processes", "warning", "Process probe returned an invalid result.")
|
||||||
|
counts.append(len(pids))
|
||||||
elif result.state == "failed":
|
elif result.state == "failed":
|
||||||
counts.append(0)
|
counts.append(0)
|
||||||
else:
|
else:
|
||||||
@@ -332,10 +385,19 @@ def check_caffeine(config: DoctorConfig) -> Check:
|
|||||||
if result.state != "ok":
|
if result.state != "ok":
|
||||||
return Check("panama.caffeine", "panama-tools", "Caffeine inhibitor", "warning", "Caffeine inhibitor probe is unavailable.")
|
return Check("panama.caffeine", "panama-tools", "Caffeine inhibitor", "warning", "Caffeine inhibitor probe is unavailable.")
|
||||||
uid = str(os.getuid())
|
uid = str(os.getuid())
|
||||||
inhibitors = sum(
|
inhibitors = 0
|
||||||
1 for line in result.stdout.splitlines()
|
malformed = False
|
||||||
if (parts := line.split()) and len(parts) >= 8 and parts[0] == "Panama" and parts[1] == uid and parts[3].isdecimal() and parts[-2:] == ["Caffeine", "block"]
|
for line in result.stdout.splitlines():
|
||||||
)
|
parts = line.split()
|
||||||
|
relevant = len(parts) >= 2 and parts[0] == "Panama" and parts[1] == uid and "Caffeine" in parts
|
||||||
|
if not relevant:
|
||||||
|
continue
|
||||||
|
if len(parts) >= 8 and parts[3].isdecimal() and parts[-2:] == ["Caffeine", "block"]:
|
||||||
|
inhibitors += 1
|
||||||
|
else:
|
||||||
|
malformed = True
|
||||||
|
if malformed:
|
||||||
|
return Check("panama.caffeine", "panama-tools", "Caffeine inhibitor", "warning", "Caffeine inhibitor probe returned an invalid result.")
|
||||||
if inhibitors > 1:
|
if inhibitors > 1:
|
||||||
return Check("panama.caffeine", "panama-tools", "Caffeine inhibitor", "warning", "Duplicate Panama Caffeine inhibitors detected.", Action("repair", "Release duplicate inhibitors"))
|
return Check("panama.caffeine", "panama-tools", "Caffeine inhibitor", "warning", "Duplicate Panama Caffeine inhibitors detected.", Action("repair", "Release duplicate inhibitors"))
|
||||||
if inhibitors == 1:
|
if inhibitors == 1:
|
||||||
@@ -362,6 +424,14 @@ def context_versions(config: DoctorConfig) -> list[dict[str, str]]:
|
|||||||
return [{"id": "hyprland", "version": parse_version(hyprland)}, {"id": "quickshell", "version": parse_version(quickshell)}, {"id": "fedora", "version": fedora}, {"id": "panama", "version": parse_version(revision, REVISION_PATTERN)}]
|
return [{"id": "hyprland", "version": parse_version(hyprland)}, {"id": "quickshell", "version": parse_version(quickshell)}, {"id": "fedora", "version": fedora}, {"id": "panama", "version": parse_version(revision, REVISION_PATTERN)}]
|
||||||
|
|
||||||
|
|
||||||
|
def unavailable_check(check_id: str) -> Check:
|
||||||
|
return Check(check_id, group_for(check_id), CHECK_TITLES[check_id], "warning", "Diagnostic probe could not be completed.")
|
||||||
|
|
||||||
|
|
||||||
|
def unavailable_versions() -> list[dict[str, str]]:
|
||||||
|
return [{"id": name, "version": "unavailable"} for name in ("hyprland", "quickshell", "fedora", "panama")]
|
||||||
|
|
||||||
|
|
||||||
def collect_checks(config: DoctorConfig) -> list[Check]:
|
def collect_checks(config: DoctorConfig) -> list[Check]:
|
||||||
probes: dict[str, Callable[[], Check]] = {
|
probes: dict[str, Callable[[], Check]] = {
|
||||||
"desktop.hyprland": lambda: check_hyprland(config), "desktop.quickshell": lambda: check_quickshell(config), "desktop.notifications": lambda: check_notifications(config), "desktop.portals": lambda: check_portals(config),
|
"desktop.hyprland": lambda: check_hyprland(config), "desktop.quickshell": lambda: check_quickshell(config), "desktop.notifications": lambda: check_notifications(config), "desktop.portals": lambda: check_portals(config),
|
||||||
@@ -372,15 +442,28 @@ def collect_checks(config: DoctorConfig) -> list[Check]:
|
|||||||
}
|
}
|
||||||
with ThreadPoolExecutor(max_workers=8) as executor:
|
with ThreadPoolExecutor(max_workers=8) as executor:
|
||||||
futures = {check_id: executor.submit(probes[check_id]) for check_id in CHECK_ORDER}
|
futures = {check_id: executor.submit(probes[check_id]) for check_id in CHECK_ORDER}
|
||||||
return [futures[check_id].result() for check_id in CHECK_ORDER]
|
checks: list[Check] = []
|
||||||
|
for check_id in CHECK_ORDER:
|
||||||
|
try:
|
||||||
|
checks.append(futures[check_id].result())
|
||||||
|
except Exception:
|
||||||
|
checks.append(unavailable_check(check_id))
|
||||||
|
return checks
|
||||||
|
|
||||||
|
|
||||||
def snapshot(config: DoctorConfig) -> dict[str, object]:
|
def snapshot(config: DoctorConfig) -> dict[str, object]:
|
||||||
checks = collect_checks(config)
|
try:
|
||||||
|
checks = collect_checks(config)
|
||||||
|
except Exception:
|
||||||
|
checks = [unavailable_check(check_id) for check_id in CHECK_ORDER]
|
||||||
counts = {status: sum(check.status == status for check in checks) for status in ("ok", "warning", "error", "unconfigured")}
|
counts = {status: sum(check.status == status for check in checks) for status in ("ok", "warning", "error", "unconfigured")}
|
||||||
overall: Literal["healthy", "warning", "error"] = "error" if counts["error"] else "warning" if counts["warning"] else "healthy"
|
overall: Literal["healthy", "warning", "error"] = "error" if counts["error"] else "warning" if counts["warning"] else "healthy"
|
||||||
session = "hyprland" if "hyprland" in os.environ.get("XDG_CURRENT_DESKTOP", "").casefold() else "other"
|
session = "hyprland" if "hyprland" in os.environ.get("XDG_CURRENT_DESKTOP", "").casefold() else "other"
|
||||||
return {"schemaVersion": 1, "generatedAt": datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"), "summary": {"status": overall, "healthy": counts["ok"], "warnings": counts["warning"], "errors": counts["error"], "unconfigured": counts["unconfigured"]}, "context": {"session": session, "versions": context_versions(config)}, "checks": [check_json(check) for check in checks]}
|
try:
|
||||||
|
versions = context_versions(config)
|
||||||
|
except Exception:
|
||||||
|
versions = unavailable_versions()
|
||||||
|
return {"schemaVersion": 1, "generatedAt": datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"), "summary": {"status": overall, "healthy": counts["ok"], "warnings": counts["warning"], "errors": counts["error"], "unconfigured": counts["unconfigured"]}, "context": {"session": session, "versions": versions}, "checks": [check_json(check) for check in checks]}
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str]) -> int:
|
def main(argv: list[str]) -> int:
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ set -euo pipefail
|
|||||||
if [[ "${PANAMA_DOCTOR_FIXTURE_BUS:-ready}" == "missing" ]]; then
|
if [[ "${PANAMA_DOCTOR_FIXTURE_BUS:-ready}" == "missing" ]]; then
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
if [[ "${PANAMA_DOCTOR_FIXTURE_BUS:-ready}" == "invalid-utf8" ]]; then
|
||||||
|
printf '\377\n'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
printf '%s\n' \
|
printf '%s\n' \
|
||||||
'org.freedesktop.portal.Desktop 1000 portal' \
|
'org.freedesktop.portal.Desktop 1000 portal' \
|
||||||
'org.kde.kdeconnect 1000 kdeconnect' \
|
'org.kde.kdeconnect 1000 kdeconnect' \
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ set -euo pipefail
|
|||||||
name="${!#}"
|
name="${!#}"
|
||||||
case ",${PANAMA_DOCTOR_FIXTURE_PROCESSES:-}," in
|
case ",${PANAMA_DOCTOR_FIXTURE_PROCESSES:-}," in
|
||||||
*",$name:duplicate,"*) printf '4101\n4102\n' ;;
|
*",$name:duplicate,"*) printf '4101\n4102\n' ;;
|
||||||
|
*",$name:malformed,"*) printf 'not-a-pid\n' ;;
|
||||||
*",$name:missing,"*) exit 1 ;;
|
*",$name:missing,"*) exit 1 ;;
|
||||||
*) printf '4101\n' ;;
|
*) printf '4101\n' ;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ -n ${PANAMA_DOCTOR_FIXTURE_PROBE_SECRET+x} ]]; then
|
||||||
|
exit 97
|
||||||
|
fi
|
||||||
|
|
||||||
service="${4:-}"
|
service="${4:-}"
|
||||||
case ",${PANAMA_DOCTOR_FIXTURE_STOPPED:-}," in
|
case ",${PANAMA_DOCTOR_FIXTURE_STOPPED:-}," in
|
||||||
*",$service,"*) exit 3 ;;
|
*",$service,"*) exit 3 ;;
|
||||||
|
|||||||
@@ -7,4 +7,7 @@ printf 'Panama %s fixture-user 4101 systemd-inhibit sleep:idle Caffeine block\n'
|
|||||||
if [[ "${PANAMA_DOCTOR_FIXTURE_CAFFEINE:-single}" == "duplicate" ]]; then
|
if [[ "${PANAMA_DOCTOR_FIXTURE_CAFFEINE:-single}" == "duplicate" ]]; then
|
||||||
printf 'Panama %s fixture-user 4102 systemd-inhibit sleep:idle Caffeine block\n' "$uid"
|
printf 'Panama %s fixture-user 4102 systemd-inhibit sleep:idle Caffeine block\n' "$uid"
|
||||||
fi
|
fi
|
||||||
|
if [[ "${PANAMA_DOCTOR_FIXTURE_CAFFEINE:-single}" == "malformed" ]]; then
|
||||||
|
printf 'Panama %s fixture-user invalid Caffeine\n' "$uid"
|
||||||
|
fi
|
||||||
printf 'Other %s fixture-secret-token AA:BB:CC:DD:EE:FF fixture clipboard body ignore ignore\n' "$uid"
|
printf 'Other %s fixture-secret-token AA:BB:CC:DD:EE:FF fixture clipboard body ignore ignore\n' "$uid"
|
||||||
|
|||||||
@@ -134,6 +134,22 @@ assert_schema_and_redaction "$snapshot"
|
|||||||
# A healthy systemd-backed service stays healthy.
|
# A healthy systemd-backed service stays healthy.
|
||||||
check_status "$snapshot" desktop.hyprpaper ok
|
check_status "$snapshot" desktop.hyprpaper ok
|
||||||
|
|
||||||
|
# Arbitrary parent environment values are not propagated into probes.
|
||||||
|
sealed_environment="$(PANAMA_DOCTOR_FIXTURE_PROBE_SECRET=fixture-secret-token run_doctor --json)"
|
||||||
|
assert_schema_and_redaction "$sealed_environment"
|
||||||
|
check_status "$sealed_environment" desktop.hyprpaper ok
|
||||||
|
|
||||||
|
# An OS-level launch failure is contained as a check result, never a failed
|
||||||
|
# doctor invocation or a partial snapshot.
|
||||||
|
chmod 0644 "$bin_dir/systemctl"
|
||||||
|
if ! launch_failure="$(run_doctor --json)"; then
|
||||||
|
chmod +x "$bin_dir/systemctl"
|
||||||
|
fail 'launch failure prevented the doctor from emitting JSON'
|
||||||
|
fi
|
||||||
|
chmod +x "$bin_dir/systemctl"
|
||||||
|
assert_schema_and_redaction "$launch_failure"
|
||||||
|
check_status "$launch_failure" desktop.hyprpaper error
|
||||||
|
|
||||||
# A missing required executable is an error rather than a crash.
|
# A missing required executable is an error rather than a crash.
|
||||||
mv "$bin_dir/qs" "$bin_dir/qs.off"
|
mv "$bin_dir/qs" "$bin_dir/qs.off"
|
||||||
missing_qs="$(run_doctor --json)"
|
missing_qs="$(run_doctor --json)"
|
||||||
@@ -183,6 +199,24 @@ check_status "$duplicated_processes" panama.processes warning
|
|||||||
! jq -r '.checks[] | select(.id == "panama.processes") | .detail' <<<"$duplicated_processes" | grep -Eq '[0-9]{3,}' \
|
! jq -r '.checks[] | select(.id == "panama.processes") | .detail' <<<"$duplicated_processes" | grep -Eq '[0-9]{3,}' \
|
||||||
|| fail 'process detail exposed a PID'
|
|| fail 'process detail exposed a PID'
|
||||||
|
|
||||||
|
# Invalid output for a non-Quickshell authored process is not a normal zero
|
||||||
|
# count that can be hidden by the running Quickshell process.
|
||||||
|
malformed_processes="$(PANAMA_DOCTOR_FIXTURE_PROCESSES=hyprpaper:malformed run_doctor --json)"
|
||||||
|
check_status "$malformed_processes" panama.processes warning
|
||||||
|
|
||||||
|
# Panama/Caffeine-shaped rows that do not satisfy the fixed inhibitor schema
|
||||||
|
# are unavailable rather than reported as a healthy no-inhibitor state.
|
||||||
|
malformed_caffeine="$(PANAMA_DOCTOR_FIXTURE_CAFFEINE=malformed run_doctor --json)"
|
||||||
|
check_status "$malformed_caffeine" panama.caffeine warning
|
||||||
|
|
||||||
|
# A decoding error raised inside a concurrent probe is converted to a complete
|
||||||
|
# snapshot rather than escaping from Future.result().
|
||||||
|
if ! invalid_probe="$(PANAMA_DOCTOR_FIXTURE_BUS=invalid-utf8 run_doctor --json)"; then
|
||||||
|
fail 'unexpected probe exception prevented the doctor from emitting JSON'
|
||||||
|
fi
|
||||||
|
assert_schema_and_redaction "$invalid_probe"
|
||||||
|
check_status "$invalid_probe" desktop.portals warning
|
||||||
|
|
||||||
# Configured Home Assistant failures route to the exact authored Settings page.
|
# Configured Home Assistant failures route to the exact authored Settings page.
|
||||||
rm "$config_home/quickshell"
|
rm "$config_home/quickshell"
|
||||||
mkdir -p "$config_home/quickshell/scripts"
|
mkdir -p "$config_home/quickshell/scripts"
|
||||||
|
|||||||
Reference in New Issue
Block a user