Compare commits

...

3 Commits

Author SHA1 Message Date
937fced108 feat: add auto secure boot to new kernel update 2026-07-28 13:16:02 +02:00
61e0e1e840 fix: fix Aur packages installation 2026-05-27 07:49:49 +02:00
084d1f5b42 fix: fix Aur detection packages 2026-05-27 07:41:44 +02:00
13 changed files with 693 additions and 10 deletions

View File

@ -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

View File

@ -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
View 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))

View File

@ -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()

View File

@ -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
View 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)

View File

@ -12,6 +12,7 @@ _DEFAULTS = {
"last_check": None,
"available_updates": [],
"pending_restart": [],
"pending_aur": [],
"installed_history": [],
"errors": [],
}

View File

@ -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)

View 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

View 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"

View File

@ -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

View 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
View 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()