Compare commits
3 Commits
8d0d9835be
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 937fced108 | |||
| 61e0e1e840 | |||
| 084d1f5b42 |
13
README.md
13
README.md
@ -9,6 +9,7 @@ Daemon de mises à jour automatiques pour CachyOS. Il vérifie les mises à jour
|
||||
- *Sûrs* : installés immédiatement en arrière-plan (aucune interruption)
|
||||
- *Reportés* : kernel, systemd, pilotes GPU, glibc, etc. — pré-téléchargés pendant la session, puis installés à la prochaine extinction
|
||||
- **Installation à l'extinction** — les paquets reportés sont installés pendant la séquence d'arrêt, sans accès réseau nécessaire (pré-téléchargés)
|
||||
- **Secure Boot conservé** — si une clé MOK existe déjà, l'ESP reste montée et les noyaux/GRUB sont resignés et vérifiés après la transaction
|
||||
- **Interface graphique** — fenêtre GTK4/Libadwaita affichant les mises à jour en attente et l'historique des installations
|
||||
|
||||
## Dépendances
|
||||
@ -19,6 +20,18 @@ Daemon de mises à jour automatiques pour CachyOS. Il vérifie les mises à jour
|
||||
- `libadwaita`
|
||||
- `pacman-contrib` (fournit `checkupdates`)
|
||||
|
||||
L'intégration Secure Boot est activée automatiquement lorsque
|
||||
`/root/secureboot-mok/MOK.key` et `/root/secureboot-mok/MOK.crt` existent. Le
|
||||
certificat correspondant doit déjà être enrôlé dans le firmware via MOK. Les
|
||||
chemins peuvent être personnalisés dans
|
||||
`/etc/cachyos-updater/secureboot.conf` :
|
||||
|
||||
```bash
|
||||
SECURE_BOOT_KEY=/root/secureboot-mok/MOK.key
|
||||
SECURE_BOOT_CERT=/root/secureboot-mok/MOK.crt
|
||||
SECURE_BOOT_ESP=/boot/efi
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
|
||||
26
install.sh
26
install.sh
@ -22,6 +22,13 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
info "Vérification des dépendances…"
|
||||
|
||||
deps=(python python-gobject gtk4 libadwaita pacman-contrib)
|
||||
|
||||
# Active l'intégration Secure Boot si une paire de clés MOK existe déjà.
|
||||
secure_boot_configured=false
|
||||
if [[ -r /root/secureboot-mok/MOK.key && -r /root/secureboot-mok/MOK.crt ]]; then
|
||||
secure_boot_configured=true
|
||||
deps+=(grub shim-signed sbsigntools)
|
||||
fi
|
||||
missing=()
|
||||
for dep in "${deps[@]}"; do
|
||||
if ! pacman -Qi "$dep" &>/dev/null; then
|
||||
@ -47,6 +54,19 @@ install -m 755 "$SCRIPT_DIR"/bin/cachyos-updater /usr/bin/cachyos-updater
|
||||
install -m 755 "$SCRIPT_DIR"/bin/cachyos-updater-ui /usr/bin/cachyos-updater-ui
|
||||
install -m 755 "$SCRIPT_DIR"/bin/cachyos-updater-shutdown /usr/bin/cachyos-updater-shutdown
|
||||
|
||||
if $secure_boot_configured; then
|
||||
install -d /usr/local/sbin /etc/pacman.d/hooks
|
||||
install -m 750 \
|
||||
"$SCRIPT_DIR"/secureboot/secureboot-sign-cachyos \
|
||||
/usr/local/sbin/secureboot-sign-cachyos
|
||||
install -m 644 \
|
||||
"$SCRIPT_DIR"/secureboot/99-secureboot-sign-cachyos.hook \
|
||||
/etc/pacman.d/hooks/99-secureboot-sign-cachyos.hook
|
||||
info "Intégration Secure Boot installée"
|
||||
else
|
||||
warn "Clés MOK absentes : intégration Secure Boot non installée"
|
||||
fi
|
||||
|
||||
# Unités systemd
|
||||
install -m 644 "$SCRIPT_DIR"/systemd/cachyos-updater.service \
|
||||
/usr/lib/systemd/system/cachyos-updater.service
|
||||
@ -77,8 +97,10 @@ systemctl daemon-reload
|
||||
systemctl enable --now cachyos-updater.timer
|
||||
info "Timer activé : vérification au démarrage puis toutes les heures"
|
||||
|
||||
# Activer le service de shutdown (installation avant extinction)
|
||||
systemctl enable cachyos-updater-shutdown.service
|
||||
# Récrée les liens pour migrer les anciennes installations qui ciblaient
|
||||
# directement poweroff.target/reboot.target, puis arme ExecStop.
|
||||
systemctl reenable --now cachyos-updater-shutdown.service
|
||||
systemctl start cachyos-updater-shutdown.service
|
||||
info "Service shutdown activé : installation des mises à jour reportées à l'extinction"
|
||||
|
||||
# ── Résumé ─────────────────────────────────────────────────────────────────
|
||||
|
||||
118
lib/aur_manager.py
Normal file
118
lib/aur_manager.py
Normal file
@ -0,0 +1,118 @@
|
||||
"""Gestion des mises à jour AUR.
|
||||
|
||||
Les paquets AUR sont toujours installés immédiatement (jamais reportés à
|
||||
l'extinction) car le build requiert un utilisateur actif et un accès réseau.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import pwd
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from package_manager import Package, InstallResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_AUR_HELPERS = ["paru", "yay"]
|
||||
|
||||
|
||||
def detect_aur_helper() -> str | None:
|
||||
for helper in _AUR_HELPERS:
|
||||
if shutil.which(helper):
|
||||
return helper
|
||||
return None
|
||||
|
||||
|
||||
def detect_active_user() -> str | None:
|
||||
"""Retourne le nom de l'utilisateur avec une session graphique active."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["loginctl", "list-sessions", "--no-legend"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
for line in result.stdout.strip().splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) < 3:
|
||||
continue
|
||||
session_id, _, user = parts[0], parts[1], parts[2]
|
||||
if user == "root":
|
||||
continue
|
||||
session_type = subprocess.run(
|
||||
["loginctl", "show-session", session_id, "-p", "Type", "--value"],
|
||||
capture_output=True, text=True, timeout=3,
|
||||
).stdout.strip()
|
||||
if session_type in ("x11", "wayland", "mir"):
|
||||
return user
|
||||
except Exception as e:
|
||||
logger.debug(f"Impossible de détecter l'utilisateur actif : {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _user_env(user: str) -> dict:
|
||||
pw = pwd.getpwnam(user)
|
||||
uid = pw.pw_uid
|
||||
env = {
|
||||
"HOME": pw.pw_dir,
|
||||
"USER": user,
|
||||
"LOGNAME": user,
|
||||
"XDG_RUNTIME_DIR": f"/run/user/{uid}",
|
||||
"PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
||||
# Évite que sudo bloque sans TTY si le helper essaie d'appeler sudo pacman
|
||||
"SUDO_ASKPASS": "/bin/false",
|
||||
}
|
||||
# Socket DBUS de session (requis par paru/yay pour le build)
|
||||
dbus_socket = Path(f"/run/user/{uid}/bus")
|
||||
if dbus_socket.exists():
|
||||
env["DBUS_SESSION_BUS_ADDRESS"] = f"unix:path={dbus_socket}"
|
||||
return env
|
||||
|
||||
|
||||
def check_aur_updates(user: str, helper: str) -> list[Package]:
|
||||
"""Liste les mises à jour AUR disponibles sans rien installer."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["sudo", "-u", user, "-H", helper, "-Qua", "--color", "never"],
|
||||
capture_output=True, text=True, timeout=120,
|
||||
env=_user_env(user),
|
||||
)
|
||||
packages = []
|
||||
for line in result.stdout.strip().splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) >= 4 and parts[2] == "->":
|
||||
packages.append(Package(
|
||||
name=parts[0],
|
||||
old_version=parts[1],
|
||||
new_version=parts[3],
|
||||
))
|
||||
return packages
|
||||
except Exception as e:
|
||||
logger.error(f"Échec de la vérification AUR : {e}")
|
||||
return []
|
||||
|
||||
|
||||
def install_aur_packages(user: str, helper: str, names: list[str]) -> InstallResult:
|
||||
"""Installe des paquets AUR spécifiques en tant qu'utilisateur actif."""
|
||||
if not names:
|
||||
return InstallResult(success=True)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"sudo", "-u", user, "-H",
|
||||
helper, "-S", "--noconfirm", "--noprogressbar",
|
||||
"--color", "never",
|
||||
] + names,
|
||||
capture_output=True, text=True, timeout=900,
|
||||
env=_user_env(user),
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return InstallResult(success=True, installed=names)
|
||||
return InstallResult(
|
||||
success=False,
|
||||
failed=names,
|
||||
error=result.stderr[-800:],
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(f"Erreur lors de l'installation AUR : {e}")
|
||||
return InstallResult(success=False, failed=names, error=str(e))
|
||||
@ -5,6 +5,10 @@ import os
|
||||
from datetime import datetime
|
||||
|
||||
import state as state_mod
|
||||
from aur_manager import (
|
||||
check_aur_updates, detect_active_user, detect_aur_helper,
|
||||
install_aur_packages,
|
||||
)
|
||||
from classifier import classify
|
||||
from config import FIRST_RUN_DELAY_SECONDS, SOCKET_PATH, UPDATE_INTERVAL_SECONDS
|
||||
from package_manager import (
|
||||
@ -37,6 +41,8 @@ class Daemon:
|
||||
logger.info("pacman est verrouillé (autre processus actif), cycle ignoré")
|
||||
return
|
||||
|
||||
asyncio.create_task(self.aur_cycle())
|
||||
|
||||
try:
|
||||
self._set_status("checking")
|
||||
logger.info("Vérification des mises à jour disponibles…")
|
||||
@ -125,6 +131,56 @@ class Daemon:
|
||||
self._add_error(str(e))
|
||||
self._set_status("error")
|
||||
|
||||
async def aur_cycle(self):
|
||||
helper = await asyncio.to_thread(detect_aur_helper)
|
||||
if not helper:
|
||||
logger.debug("Aucun helper AUR trouvé (paru/yay), AUR ignoré")
|
||||
return
|
||||
|
||||
user = await asyncio.to_thread(detect_active_user)
|
||||
if not user:
|
||||
logger.info("Aucune session graphique active, AUR ignoré ce cycle")
|
||||
return
|
||||
|
||||
logger.info(f"Vérification des mises à jour AUR avec {helper} (user={user})…")
|
||||
packages = await asyncio.to_thread(check_aur_updates, user, helper)
|
||||
|
||||
# Fusionner avec les paquets AUR en attente du cycle précédent
|
||||
existing = {p["name"] for p in self._state.get("pending_aur", [])}
|
||||
for pkg in packages:
|
||||
if pkg.name not in existing:
|
||||
self._state.setdefault("pending_aur", []).append({
|
||||
"name": pkg.name,
|
||||
"old_version": pkg.old_version,
|
||||
"new_version": pkg.new_version,
|
||||
"queued_at": datetime.now().isoformat(),
|
||||
})
|
||||
existing.add(pkg.name)
|
||||
self._save()
|
||||
|
||||
all_aur = self._state.get("pending_aur", [])
|
||||
if not all_aur:
|
||||
logger.info("Aucune mise à jour AUR disponible")
|
||||
return
|
||||
|
||||
names = [p["name"] for p in all_aur]
|
||||
logger.info(f"{len(names)} mise(s) à jour AUR : {', '.join(names)}")
|
||||
|
||||
result = await asyncio.to_thread(install_aur_packages, user, helper, names)
|
||||
|
||||
if result.success:
|
||||
logger.info("Mises à jour AUR installées avec succès")
|
||||
now = datetime.now().isoformat()
|
||||
for pkg in all_aur:
|
||||
self._state["installed_history"].append({**pkg, "installed_at": now})
|
||||
self._state["installed_history"] = self._state["installed_history"][-200:]
|
||||
self._state["pending_aur"] = []
|
||||
else:
|
||||
logger.error(f"Échec installation AUR : {result.error}")
|
||||
self._add_error(f"Échec AUR : {result.error}")
|
||||
|
||||
self._save()
|
||||
|
||||
async def install_pending_cycle(self):
|
||||
if self._state["status"] not in ("idle", "error"):
|
||||
logger.info("Une opération est déjà en cours, installation ignorée")
|
||||
@ -190,6 +246,9 @@ class Daemon:
|
||||
elif action == "install_pending":
|
||||
asyncio.create_task(self.install_pending_cycle())
|
||||
response = {"status": "ok", "message": "Installation lancée"}
|
||||
elif action == "install_aur":
|
||||
asyncio.create_task(self.aur_cycle())
|
||||
response = {"status": "ok", "message": "Installation AUR lancée"}
|
||||
elif action == "get_state":
|
||||
response = {"status": "ok", "state": self._state}
|
||||
else:
|
||||
@ -206,6 +265,18 @@ class Daemon:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _aur_pending_loop(self):
|
||||
"""Réessaie l'installation AUR toutes les 5 min si des paquets sont en attente.
|
||||
|
||||
Nécessaire car le cycle AUR au démarrage échoue souvent (aucun utilisateur
|
||||
connecté), et le prochain cycle horaire est trop tardif.
|
||||
"""
|
||||
while self._running:
|
||||
await asyncio.sleep(300)
|
||||
if self._state.get("pending_aur"):
|
||||
logger.info("Paquets AUR en attente — nouvelle tentative d'installation")
|
||||
await self.aur_cycle()
|
||||
|
||||
async def run(self):
|
||||
if SOCKET_PATH.exists():
|
||||
SOCKET_PATH.unlink()
|
||||
@ -231,6 +302,8 @@ class Daemon:
|
||||
await asyncio.sleep(FIRST_RUN_DELAY_SECONDS)
|
||||
await self.update_cycle()
|
||||
|
||||
asyncio.create_task(self._aur_pending_loop())
|
||||
|
||||
while self._running:
|
||||
await asyncio.sleep(UPDATE_INTERVAL_SECONDS)
|
||||
await self.update_cycle()
|
||||
|
||||
@ -4,6 +4,8 @@ from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from secure_boot import prepare_secure_boot, refresh_secure_boot
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PACMAN_LOCK = Path("/var/lib/pacman/db.lck")
|
||||
@ -75,12 +77,30 @@ def install_packages(names: list[str]) -> InstallResult:
|
||||
if not names:
|
||||
return InstallResult(success=True)
|
||||
|
||||
secure_boot = prepare_secure_boot(names)
|
||||
if not secure_boot.success:
|
||||
return InstallResult(
|
||||
success=False,
|
||||
failed=names,
|
||||
error=f"Préparation Secure Boot impossible : {secure_boot.error}",
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
["pacman", "-S", "--noconfirm", "--needed", "--noprogressbar"] + names,
|
||||
capture_output=True, text=True, timeout=600
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
secure_boot = refresh_secure_boot(names)
|
||||
if not secure_boot.success:
|
||||
return InstallResult(
|
||||
success=False,
|
||||
failed=names,
|
||||
error=(
|
||||
"Les paquets ont été installés, mais la chaîne de démarrage "
|
||||
f"n'a pas pu être sécurisée : {secure_boot.error}"
|
||||
),
|
||||
)
|
||||
return InstallResult(success=True, installed=names)
|
||||
else:
|
||||
return InstallResult(
|
||||
@ -100,12 +120,30 @@ def upgrade_cached_packages(expected_names: list[str]) -> InstallResult:
|
||||
if not expected_names:
|
||||
return InstallResult(success=True)
|
||||
|
||||
secure_boot = prepare_secure_boot(expected_names)
|
||||
if not secure_boot.success:
|
||||
return InstallResult(
|
||||
success=False,
|
||||
failed=expected_names,
|
||||
error=f"Préparation Secure Boot impossible : {secure_boot.error}",
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
["pacman", "-Su", "--noconfirm", "--noprogressbar"],
|
||||
capture_output=True, text=True, timeout=600
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
secure_boot = refresh_secure_boot(expected_names)
|
||||
if not secure_boot.success:
|
||||
return InstallResult(
|
||||
success=False,
|
||||
failed=expected_names,
|
||||
error=(
|
||||
"Les paquets ont été installés, mais la chaîne de démarrage "
|
||||
f"n'a pas pu être sécurisée : {secure_boot.error}"
|
||||
),
|
||||
)
|
||||
return InstallResult(success=True, installed=expected_names)
|
||||
else:
|
||||
return InstallResult(
|
||||
|
||||
113
lib/secure_boot.py
Normal file
113
lib/secure_boot.py
Normal file
@ -0,0 +1,113 @@
|
||||
"""Intégration Secure Boot pour les transactions qui modifient le démarrage."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SIGNER_PATH = Path("/usr/local/sbin/secureboot-sign-cachyos")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SecureBootResult:
|
||||
success: bool
|
||||
configured: bool
|
||||
error: str | None = None
|
||||
|
||||
|
||||
def boot_artifacts_may_change(package_names: Iterable[str]) -> bool:
|
||||
"""Indique si la transaction peut remplacer un noyau ou un chargeur EFI."""
|
||||
for name in package_names:
|
||||
if name in {"grub", "shim-signed", "systemd"}:
|
||||
return True
|
||||
if name == "linux" or name.startswith("linux-"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _run(command: list[str], timeout: int = 120) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
|
||||
def _error_message(result: subprocess.CompletedProcess) -> str:
|
||||
output = (result.stderr or result.stdout).strip()
|
||||
return output[-800:] if output else f"code de sortie {result.returncode}"
|
||||
|
||||
|
||||
def prepare_secure_boot(package_names: Iterable[str]) -> SecureBootResult:
|
||||
"""Garantit que l'ESP est montée avant une transaction sensible.
|
||||
|
||||
L'absence du signer signifie simplement que Secure Boot n'est pas géré par
|
||||
cette installation. En revanche, une configuration présente mais
|
||||
inutilisable bloque la transaction pour ne pas installer un noyau non signé.
|
||||
"""
|
||||
names = list(package_names)
|
||||
if not boot_artifacts_may_change(names) or not SIGNER_PATH.is_file():
|
||||
return SecureBootResult(success=True, configured=False)
|
||||
|
||||
if os.geteuid() != 0:
|
||||
return SecureBootResult(
|
||||
success=False,
|
||||
configured=True,
|
||||
error="la préparation Secure Boot doit être exécutée en root",
|
||||
)
|
||||
|
||||
if not os.access(SIGNER_PATH, os.X_OK):
|
||||
return SecureBootResult(
|
||||
success=False,
|
||||
configured=True,
|
||||
error=f"le signer Secure Boot n'est pas exécutable : {SIGNER_PATH}",
|
||||
)
|
||||
|
||||
try:
|
||||
result = _run([str(SIGNER_PATH), "--prepare"])
|
||||
except (OSError, subprocess.TimeoutExpired) as error:
|
||||
return SecureBootResult(
|
||||
success=False,
|
||||
configured=True,
|
||||
error=f"impossible d'exécuter la pré-vérification : {error}",
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
return SecureBootResult(
|
||||
success=False,
|
||||
configured=True,
|
||||
error=f"pré-vérification du signer échouée : {_error_message(result)}",
|
||||
)
|
||||
|
||||
return SecureBootResult(success=True, configured=True)
|
||||
|
||||
|
||||
def refresh_secure_boot(package_names: Iterable[str]) -> SecureBootResult:
|
||||
"""Reconstruit et signe la chaîne CachyOS après la transaction pacman."""
|
||||
names = list(package_names)
|
||||
if not boot_artifacts_may_change(names) or not SIGNER_PATH.is_file():
|
||||
return SecureBootResult(success=True, configured=False)
|
||||
|
||||
try:
|
||||
result = _run([str(SIGNER_PATH)], timeout=180)
|
||||
except (OSError, subprocess.TimeoutExpired) as error:
|
||||
return SecureBootResult(
|
||||
success=False,
|
||||
configured=True,
|
||||
error=f"impossible d'exécuter le signer : {error}",
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
return SecureBootResult(
|
||||
success=False,
|
||||
configured=True,
|
||||
error=f"signature Secure Boot échouée : {_error_message(result)}",
|
||||
)
|
||||
|
||||
logger.info("Chaîne de démarrage Secure Boot reconstruite et vérifiée")
|
||||
return SecureBootResult(success=True, configured=True)
|
||||
@ -12,6 +12,7 @@ _DEFAULTS = {
|
||||
"last_check": None,
|
||||
"available_updates": [],
|
||||
"pending_restart": [],
|
||||
"pending_aur": [],
|
||||
"installed_history": [],
|
||||
"errors": [],
|
||||
}
|
||||
|
||||
66
lib/ui.py
66
lib/ui.py
@ -20,6 +20,7 @@ class UpdaterWindow(Adw.ApplicationWindow):
|
||||
self.set_resizable(True)
|
||||
|
||||
self._pending_rows: list[Adw.ActionRow] = []
|
||||
self._aur_rows: list[Adw.ActionRow] = []
|
||||
self._recent_rows: list[Adw.ActionRow] = []
|
||||
|
||||
self._build_ui()
|
||||
@ -68,6 +69,21 @@ class UpdaterWindow(Adw.ApplicationWindow):
|
||||
|
||||
main_box.append(self.pending_group)
|
||||
|
||||
# Groupe "Mises à jour AUR"
|
||||
self.aur_group = Adw.PreferencesGroup()
|
||||
self.aur_group.set_title("Mises à jour AUR")
|
||||
self.aur_group.set_description(
|
||||
"Ces paquets seront installés automatiquement lors de la prochaine session"
|
||||
)
|
||||
|
||||
self.install_aur_btn = Gtk.Button.new_with_label("Installer maintenant")
|
||||
self.install_aur_btn.add_css_class("suggested-action")
|
||||
self.install_aur_btn.add_css_class("pill")
|
||||
self.install_aur_btn.connect("clicked", self._on_install_aur_clicked)
|
||||
self.aur_group.set_header_suffix(self.install_aur_btn)
|
||||
|
||||
main_box.append(self.aur_group)
|
||||
|
||||
# Groupe "Récemment installées"
|
||||
self.recent_group = Adw.PreferencesGroup()
|
||||
self.recent_group.set_title("Récemment installées")
|
||||
@ -185,6 +201,7 @@ class UpdaterWindow(Adw.ApplicationWindow):
|
||||
def _update_ui(self, state: dict):
|
||||
status = state.get("status", "idle")
|
||||
pending = state.get("pending_restart", [])
|
||||
pending_aur = state.get("pending_aur", [])
|
||||
history = state.get("installed_history", [])
|
||||
last_check = state.get("last_check")
|
||||
errors = state.get("errors", [])
|
||||
@ -240,10 +257,13 @@ class UpdaterWindow(Adw.ApplicationWindow):
|
||||
pass
|
||||
self.status_sub_lbl.set_text(sub)
|
||||
|
||||
# ── Bouton "Installer maintenant" ────────────────────────────────
|
||||
# ── Bouton "Installer maintenant" (officiel) ─────────────────────
|
||||
can_install = status in ("idle", "error") and bool(pending)
|
||||
self.install_now_btn.set_sensitive(can_install)
|
||||
|
||||
# ── Bouton "Installer maintenant" (AUR) ──────────────────────────
|
||||
self.install_aur_btn.set_sensitive(bool(pending_aur))
|
||||
|
||||
# ── Liste des paquets en attente de redémarrage ──────────────────
|
||||
for row in self._pending_rows:
|
||||
self.pending_group.remove(row)
|
||||
@ -263,6 +283,25 @@ class UpdaterWindow(Adw.ApplicationWindow):
|
||||
|
||||
self.pending_group.set_visible(bool(pending))
|
||||
|
||||
# ── Liste des paquets AUR en attente ─────────────────────────────
|
||||
for row in self._aur_rows:
|
||||
self.aur_group.remove(row)
|
||||
self._aur_rows.clear()
|
||||
|
||||
for pkg in pending_aur:
|
||||
row = Adw.ActionRow()
|
||||
row.set_title(GLib.markup_escape_text(pkg.get("name", "")))
|
||||
row.set_subtitle(
|
||||
f"{pkg.get('old_version', '')} → {pkg.get('new_version', '')}"
|
||||
)
|
||||
icon = Gtk.Image.new_from_icon_name("application-x-addon-symbolic")
|
||||
icon.add_css_class("accent")
|
||||
row.add_prefix(icon)
|
||||
self.aur_group.add(row)
|
||||
self._aur_rows.append(row)
|
||||
|
||||
self.aur_group.set_visible(bool(pending_aur))
|
||||
|
||||
# ── Historique des installations récentes ────────────────────────
|
||||
for row in self._recent_rows:
|
||||
self.recent_group.remove(row)
|
||||
@ -321,6 +360,10 @@ class UpdaterWindow(Adw.ApplicationWindow):
|
||||
self.install_now_btn.set_sensitive(False)
|
||||
threading.Thread(target=self._send_install_pending, daemon=True).start()
|
||||
|
||||
def _on_install_aur_clicked(self, _btn):
|
||||
self.install_aur_btn.set_sensitive(False)
|
||||
threading.Thread(target=self._send_install_aur, daemon=True).start()
|
||||
|
||||
def _send_install_pending(self):
|
||||
try:
|
||||
s = sock_module.socket(sock_module.AF_UNIX, sock_module.SOCK_STREAM)
|
||||
@ -342,6 +385,27 @@ class UpdaterWindow(Adw.ApplicationWindow):
|
||||
)
|
||||
GLib.idle_add(self.install_now_btn.set_sensitive, True)
|
||||
|
||||
def _send_install_aur(self):
|
||||
try:
|
||||
s = sock_module.socket(sock_module.AF_UNIX, sock_module.SOCK_STREAM)
|
||||
s.settimeout(5)
|
||||
s.connect(str(SOCKET_PATH))
|
||||
s.sendall(json.dumps({"action": "install_aur"}).encode())
|
||||
s.recv(1024)
|
||||
s.close()
|
||||
except PermissionError:
|
||||
GLib.idle_add(
|
||||
self._show_toast,
|
||||
"Permission refusée — êtes-vous dans le groupe wheel ?",
|
||||
)
|
||||
GLib.idle_add(self.install_aur_btn.set_sensitive, True)
|
||||
except Exception as e:
|
||||
GLib.idle_add(
|
||||
self._show_toast,
|
||||
f"Impossible de contacter le service : {e}",
|
||||
)
|
||||
GLib.idle_add(self.install_aur_btn.set_sensitive, True)
|
||||
|
||||
def _send_check_now(self):
|
||||
try:
|
||||
s = sock_module.socket(sock_module.AF_UNIX, sock_module.SOCK_STREAM)
|
||||
|
||||
20
secureboot/99-secureboot-sign-cachyos.hook
Normal file
20
secureboot/99-secureboot-sign-cachyos.hook
Normal file
@ -0,0 +1,20 @@
|
||||
[Trigger]
|
||||
Operation = Install
|
||||
Operation = Upgrade
|
||||
Type = Package
|
||||
Target = grub
|
||||
Target = shim-signed
|
||||
|
||||
[Trigger]
|
||||
Operation = Install
|
||||
Operation = Upgrade
|
||||
Type = Path
|
||||
Target = boot/vmlinuz-*
|
||||
|
||||
[Action]
|
||||
Description = Signing and verifying CachyOS Secure Boot artifacts...
|
||||
When = PostTransaction
|
||||
Depends = grub
|
||||
Depends = shim-signed
|
||||
Depends = sbsigntools
|
||||
Exec = /usr/local/sbin/secureboot-sign-cachyos
|
||||
112
secureboot/secureboot-sign-cachyos
Executable file
112
secureboot/secureboot-sign-cachyos
Executable file
@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
CONFIG=/etc/cachyos-updater/secureboot.conf
|
||||
if [[ -r "$CONFIG" ]]; then
|
||||
# Le fichier est installé et contrôlé par root.
|
||||
# shellcheck source=/dev/null
|
||||
source "$CONFIG"
|
||||
fi
|
||||
|
||||
KEY=${SECURE_BOOT_KEY:-/root/secureboot-mok/MOK.key}
|
||||
CERT=${SECURE_BOOT_CERT:-/root/secureboot-mok/MOK.crt}
|
||||
ESP=${SECURE_BOOT_ESP:-/boot/efi}
|
||||
SBAT=${SECURE_BOOT_SBAT:-/usr/share/grub/sbat.csv}
|
||||
GRUB_MODULES="all_video bli boot chain configfile echo efi_gop efi_uga ext2 fat font gettext gfxmenu gfxterm gzio linux loadenv normal part_gpt part_msdos png search search_fs_uuid search_label terminal video video_bochs video_cirrus"
|
||||
|
||||
die() {
|
||||
echo "Secure Boot : $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
[[ $EUID -eq 0 ]] || die "le signer doit être exécuté en root"
|
||||
|
||||
for command in findmnt grub-install install sbverify sbsign; do
|
||||
command -v "$command" >/dev/null 2>&1 \
|
||||
|| die "commande requise introuvable : $command"
|
||||
done
|
||||
|
||||
[[ -r "$KEY" ]] || die "clé privée introuvable : $KEY"
|
||||
[[ -r "$CERT" ]] || die "certificat introuvable : $CERT"
|
||||
[[ -r "$SBAT" ]] || die "fichier SBAT introuvable : $SBAT"
|
||||
|
||||
if ! findmnt --mountpoint "$ESP" >/dev/null 2>&1; then
|
||||
echo "Secure Boot : montage de $ESP"
|
||||
mount "$ESP"
|
||||
fi
|
||||
|
||||
ESP_FS=$(findmnt --noheadings --output FSTYPE --mountpoint "$ESP" | tr -d '[:space:]')
|
||||
[[ "$ESP_FS" == "vfat" ]] \
|
||||
|| die "$ESP n'est pas une partition EFI vfat montée (type : ${ESP_FS:-inconnu})"
|
||||
|
||||
if [[ ${1:-} == "--prepare" ]]; then
|
||||
echo "Secure Boot : pré-vérification réussie sur $ESP"
|
||||
exit 0
|
||||
elif [[ $# -gt 0 ]]; then
|
||||
die "option inconnue : $1"
|
||||
fi
|
||||
|
||||
sign_if_needed() {
|
||||
local file=$1
|
||||
local tmp
|
||||
|
||||
[[ -f "$file" ]] || return 0
|
||||
if sbverify --cert "$CERT" "$file" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
tmp="${file}.signed.$$"
|
||||
trap 'rm -f "$tmp"' RETURN
|
||||
sbsign --key "$KEY" --cert "$CERT" --output "$tmp" "$file" >/dev/null
|
||||
chown --reference="$file" "$tmp"
|
||||
chmod --reference="$file" "$tmp"
|
||||
mv "$tmp" "$file"
|
||||
trap - RETURN
|
||||
}
|
||||
|
||||
verify_signature() {
|
||||
local file=$1
|
||||
sbverify --cert "$CERT" "$file" >/dev/null 2>&1 \
|
||||
|| die "signature MOK invalide : $file"
|
||||
}
|
||||
|
||||
grub-install \
|
||||
--target=x86_64-efi \
|
||||
--efi-directory="$ESP" \
|
||||
--bootloader-id=CACHYOS \
|
||||
--modules="$GRUB_MODULES" \
|
||||
--sbat="$SBAT" \
|
||||
--no-nvram
|
||||
|
||||
install -d "$ESP/EFI/CACHYOS" "$ESP/EFI/BOOT"
|
||||
for component in shimx64.efi mmx64.efi fbx64.efi; do
|
||||
[[ -r "/usr/share/shim-signed/$component" ]] \
|
||||
|| die "composant shim introuvable : $component"
|
||||
install -m 0644 \
|
||||
"/usr/share/shim-signed/$component" \
|
||||
"$ESP/EFI/CACHYOS/${component^^}"
|
||||
install -m 0644 \
|
||||
"/usr/share/shim-signed/$component" \
|
||||
"$ESP/EFI/BOOT/${component^^}"
|
||||
done
|
||||
|
||||
sign_if_needed "$ESP/EFI/CACHYOS/GRUBX64.EFI"
|
||||
install -m 0644 \
|
||||
"$ESP/EFI/CACHYOS/GRUBX64.EFI" \
|
||||
"$ESP/EFI/BOOT/GRUBX64.EFI"
|
||||
|
||||
shopt -s nullglob
|
||||
kernels=(/boot/vmlinuz-*)
|
||||
[[ ${#kernels[@]} -gt 0 ]] || die "aucun noyau trouvé dans /boot"
|
||||
for kernel in "${kernels[@]}"; do
|
||||
sign_if_needed "$kernel"
|
||||
done
|
||||
|
||||
verify_signature "$ESP/EFI/CACHYOS/GRUBX64.EFI"
|
||||
verify_signature "$ESP/EFI/BOOT/GRUBX64.EFI"
|
||||
for kernel in "${kernels[@]}"; do
|
||||
verify_signature "$kernel"
|
||||
done
|
||||
|
||||
sync
|
||||
echo "Secure Boot : GRUB et ${#kernels[@]} noyau(x) signés et vérifiés"
|
||||
@ -1,19 +1,20 @@
|
||||
[Unit]
|
||||
Description=CachyOS Updater - Installation des mises à jour reportées avant extinction
|
||||
DefaultDependencies=no
|
||||
Before=poweroff.target halt.target reboot.target shutdown.target
|
||||
# Après Plymouth pour pouvoir lui envoyer des messages
|
||||
# L'ordre est inversé à l'arrêt : le service s'arrête avant les montages.
|
||||
After=local-fs.target plymouth-start.service
|
||||
# Maintient explicitement l'ESP jusqu'à la fin de ExecStop.
|
||||
RequiresMountsFor=/boot/efi
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/bin/cachyos-updater-shutdown
|
||||
ExecStart=/usr/bin/true
|
||||
ExecStop=/usr/bin/cachyos-updater-shutdown
|
||||
RemainAfterExit=yes
|
||||
# Jusqu'à 10 minutes pour installer les mises à jour (kernel, systemd...)
|
||||
TimeoutStartSec=600
|
||||
RemainAfterExit=no
|
||||
TimeoutStopSec=600
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=cachyos-updater-shutdown
|
||||
|
||||
[Install]
|
||||
WantedBy=poweroff.target halt.target reboot.target
|
||||
WantedBy=multi-user.target
|
||||
|
||||
55
tests/test_package_manager_secure_boot.py
Normal file
55
tests/test_package_manager_secure_boot.py
Normal file
@ -0,0 +1,55 @@
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "lib"))
|
||||
|
||||
import package_manager
|
||||
from secure_boot import SecureBootResult
|
||||
|
||||
|
||||
class SecureBootTransactionTests(unittest.TestCase):
|
||||
@patch.object(package_manager.subprocess, "run")
|
||||
@patch.object(package_manager, "prepare_secure_boot")
|
||||
def test_aborts_before_pacman_when_preflight_fails(
|
||||
self, prepare_mock, run_mock
|
||||
):
|
||||
prepare_mock.return_value = SecureBootResult(
|
||||
success=False,
|
||||
configured=True,
|
||||
error="ESP indisponible",
|
||||
)
|
||||
|
||||
result = package_manager.install_packages(["linux-cachyos"])
|
||||
|
||||
self.assertFalse(result.success)
|
||||
self.assertIn("ESP indisponible", result.error)
|
||||
run_mock.assert_not_called()
|
||||
|
||||
@patch.object(package_manager, "refresh_secure_boot")
|
||||
@patch.object(package_manager.subprocess, "run")
|
||||
@patch.object(package_manager, "prepare_secure_boot")
|
||||
def test_reports_post_transaction_signing_failure(
|
||||
self, prepare_mock, run_mock, refresh_mock
|
||||
):
|
||||
prepare_mock.return_value = SecureBootResult(
|
||||
success=True,
|
||||
configured=True,
|
||||
)
|
||||
run_mock.return_value = Mock(returncode=0, stdout="", stderr="")
|
||||
refresh_mock.return_value = SecureBootResult(
|
||||
success=False,
|
||||
configured=True,
|
||||
error="signature invalide",
|
||||
)
|
||||
|
||||
result = package_manager.upgrade_cached_packages(["linux"])
|
||||
|
||||
self.assertFalse(result.success)
|
||||
self.assertIn("paquets ont été installés", result.error)
|
||||
self.assertIn("signature invalide", result.error)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
53
tests/test_secure_boot.py
Normal file
53
tests/test_secure_boot.py
Normal file
@ -0,0 +1,53 @@
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "lib"))
|
||||
|
||||
import secure_boot
|
||||
|
||||
|
||||
class BootArtifactsMayChangeTests(unittest.TestCase):
|
||||
def test_detects_kernel_and_bootloader_packages(self):
|
||||
for name in ("linux", "linux-cachyos", "linux-zen", "grub", "shim-signed"):
|
||||
with self.subTest(name=name):
|
||||
self.assertTrue(secure_boot.boot_artifacts_may_change([name]))
|
||||
|
||||
def test_ignores_unrelated_packages(self):
|
||||
self.assertFalse(
|
||||
secure_boot.boot_artifacts_may_change(["firefox", "mesa", "curl"])
|
||||
)
|
||||
|
||||
|
||||
class PrepareSecureBootTests(unittest.TestCase):
|
||||
@patch.object(secure_boot, "SIGNER_PATH")
|
||||
def test_skips_when_signer_is_not_configured(self, signer_path):
|
||||
signer_path.is_file.return_value = False
|
||||
result = secure_boot.prepare_secure_boot(["linux-cachyos"])
|
||||
self.assertTrue(result.success)
|
||||
self.assertFalse(result.configured)
|
||||
|
||||
@patch.object(secure_boot, "_run")
|
||||
@patch.object(os, "access", return_value=True)
|
||||
@patch.object(os, "geteuid", return_value=0)
|
||||
@patch.object(secure_boot, "SIGNER_PATH")
|
||||
def test_mounts_and_validates_esp(
|
||||
self, signer_path, _geteuid, _access, run_mock
|
||||
):
|
||||
signer_path.is_file.return_value = True
|
||||
signer_path.__str__ = Mock(return_value="/usr/local/sbin/signer")
|
||||
run_mock.return_value = Mock(returncode=0, stdout="", stderr="")
|
||||
|
||||
result = secure_boot.prepare_secure_boot(["linux-cachyos"])
|
||||
|
||||
self.assertTrue(result.success)
|
||||
self.assertTrue(result.configured)
|
||||
run_mock.assert_called_once_with(
|
||||
["/usr/local/sbin/signer", "--prepare"]
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user