From 6f9b80e67cbee146f3d943d343903214078fa473 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lomig=20Me=CC=81gard?= Date: Fri, 30 Oct 2026 21:19:53 +0100 Subject: [PATCH] feat(img): choose the keyboard layout and time zone at boot Add a tty1 setup screen for choosing and testing the keyboard layout and choosing the time zone before the session starts. Apply the choices to the console and session, and restore US and UTC if setup fails. The setup screen runs as rite with only CAP_SYS_TTY_CONFIG. It takes every setting from a menu and makes the settings file root-owned before the sessions start. --- docs/development/img.md | 25 ++- img/mkosi/mkosi.conf | 6 + img/mkosi/mkosi.extra/usr/lib/rite/rite-kiosk | 15 ++ .../mkosi.extra/usr/lib/rite/rite-session | 6 + img/mkosi/mkosi.extra/usr/lib/rite/rite-setup | 198 ++++++++++++++++++ .../mkosi.extra/usr/lib/rite/setup-values | 56 +++++ .../lib/systemd/system-preset/10-rite.preset | 1 + .../usr/lib/systemd/system/rite-setup.service | 40 ++++ img/mkosi/mkosi.postinst.chroot | 5 + 9 files changed, 343 insertions(+), 9 deletions(-) create mode 100755 img/mkosi/mkosi.extra/usr/lib/rite/rite-setup create mode 100644 img/mkosi/mkosi.extra/usr/lib/rite/setup-values create mode 100644 img/mkosi/mkosi.extra/usr/lib/systemd/system/rite-setup.service diff --git a/docs/development/img.md b/docs/development/img.md index 6e81417..95cb178 100644 --- a/docs/development/img.md +++ b/docs/development/img.md @@ -301,12 +301,20 @@ The boot menu cannot edit the entries: their kernel command line, root hash incl part of the UKI. With Secure Boot off, a UEFI shell can still start the UKI with another command line (see [What Rite Live proves](#what-rite-live-proves)). -The session starts on its own as the `rite` user. Its start screen shows the `rite` -version, the ceremony definition with its SHA-256, where the outputs go, and the `rite run` -command for the ceremony. Pressing Enter starts `rite run` with that ceremony, and -`rite run` asks for any parameter the ceremony leaves open. For other flags, such as -`--dry-run` or `--role`, the operator types the word `shell` at the prompt and runs the -command by hand. +Before the session, the setup screen lets the operator choose a keyboard layout and a +time zone. US and UTC are the defaults. The layout is loaded and tested before it is +kept. The layout applies to the text console and both terminals; the time zone applies +to times shown by the session, including `date`. Transcripts always use UTC. The +displayed time comes from the machine clock. If it is wrong, power off from the setup +screen and correct it in the firmware settings. Setup runs once per boot. The session +start screen shows the selected layout and time zone. + +The session then starts as the `rite` user. Its start screen shows the `rite` version, +the ceremony definition and its SHA-256, where outputs go, and the `rite run` command +for the ceremony. It also shows the keyboard layout and time zone in use. Pressing +Enter starts `rite run` with that ceremony, and `rite run` asks for any parameter the +ceremony leaves open. For other flags, such as `--dry-run` or `--role`, the operator +types the word `shell` at the prompt and runs the command by hand. When the outputs partition is missing or does not mount, the start screen says so and offers no Enter shortcut: the ceremony's outputs would be lost at power off, so running it @@ -339,8 +347,6 @@ driver. A ceremony that needs an air gap establishes it physically, by leaving o cable, and records it as a witnessed confirmation step, such as `verify_air_gap` in `examples/showcase/demo.rite.yaml`. -The keyboard layout is US. - ### Known hardware issues - **Secure Boot.** The setting is named differently from one firmware to another. On ASUS @@ -447,7 +453,8 @@ any interaction with the boot process. Observation does not prove what booted. | `img/mkosi/mkosi.extra/` | Files copied into the image, at their paths in it | | `.../efi/loader/loader.conf` | Boot menu: timeout, no editing | | `.../usr/lib/systemd/system/rite-session.service`, `rite-shell.service`, `.../etc/pam.d/rite-session` | The session on tty1 and the shell on tty2, run as `rite` | -| `.../usr/lib/rite/rite-kiosk` | Starts the session or the shell in cage and foot, or on the text console | +| `.../usr/lib/systemd/system/rite-setup.service`, `.../usr/lib/rite/rite-setup`, `setup-values` | The setup screen: keyboard layout and time zone, chosen before the session | +| `.../usr/lib/rite/rite-kiosk` | Starts the session or the shell in cage and foot, or on the text console, with the layout and zone chosen at setup | | `.../usr/lib/rite/rite-session`, `rite-shell` | The operator's start screen, and the second terminal | | `.../usr/lib/systemd/system/rite-ceremony-load.service`, `.../usr/lib/rite/load-ceremony` | Copies the ceremony layer into memory and records its hash | | `.../usr/lib/rite/mount-outputs`, `boot-partition` | Mounts `RITE-OUT` at `/rite/outputs`; finds a partition on the boot disk | diff --git a/img/mkosi/mkosi.conf b/img/mkosi/mkosi.conf index a832262..f6f3d84 100644 --- a/img/mkosi/mkosi.conf +++ b/img/mkosi/mkosi.conf @@ -168,6 +168,12 @@ Packages= # The boot loader and the UKI stub, which mkosi takes from the image. systemd-boot-efi + # rite-setup uses whiptail for menus, ckbcomp to convert XKB layouts, + # and loadkeys to load the resulting console keymap. + whiptail + console-setup + kbd + # Pager, for man and the shell. less diff --git a/img/mkosi/mkosi.extra/usr/lib/rite/rite-kiosk b/img/mkosi/mkosi.extra/usr/lib/rite/rite-kiosk index c4795a7..9afe3cb 100755 --- a/img/mkosi/mkosi.extra/usr/lib/rite/rite-kiosk +++ b/img/mkosi/mkosi.extra/usr/lib/rite/rite-kiosk @@ -9,6 +9,21 @@ program=$1 +# Apply the settings chosen in rite-setup: cage reads the XKB variables and +# session programs read TZ. The settings file is root-owned before the session +# starts. If it is absent or invalid, the session uses US and UTC. +# shellcheck disable=SC1091 +source /usr/lib/rite/setup-values +if [[ -r $SETTINGS ]]; then + while IFS='=' read -r key value; do + case $key in + layout) valid_layout "$value" && export XKB_DEFAULT_LAYOUT=$value ;; + variant) valid_variant "$value" && export XKB_DEFAULT_VARIANT=$value ;; + timezone) valid_zone "$value" && export TZ=$value ;; + esac + done < "$SETTINGS" +fi + if ! grep -qwF 'rite.console' /proc/cmdline; then # cage takes the display, which only the terminal on screen can do: the # second terminal starts cage when the operator first switches to it. diff --git a/img/mkosi/mkosi.extra/usr/lib/rite/rite-session b/img/mkosi/mkosi.extra/usr/lib/rite/rite-session index 425f772..103c963 100755 --- a/img/mkosi/mkosi.extra/usr/lib/rite/rite-session +++ b/img/mkosi/mkosi.extra/usr/lib/rite/rite-session @@ -69,6 +69,12 @@ else echo "WARNING: no RITE-OUT partition. Outputs in $OUTPUTS are lost at power off." fi echo "" +# Set by rite-kiosk from the choice made in rite-setup. +# shellcheck disable=SC1091 +source /usr/lib/rite/setup-values +echo "Keyboard: $(layout_name "${XKB_DEFAULT_LAYOUT:-us}" "${XKB_DEFAULT_VARIANT:-}")" +echo "Time zone: ${TZ:-UTC}" +echo "" echo "Terminals: Ctrl+Alt+F2 opens a second terminal, Ctrl+Alt+F1 returns here." echo "" diff --git a/img/mkosi/mkosi.extra/usr/lib/rite/rite-setup b/img/mkosi/mkosi.extra/usr/lib/rite/rite-setup new file mode 100755 index 0000000..b260ba5 --- /dev/null +++ b/img/mkosi/mkosi.extra/usr/lib/rite/rite-setup @@ -0,0 +1,198 @@ +#!/bin/bash +# /usr/lib/rite/rite-setup +# The keyboard layout and time zone, chosen from menus on tty1 before the +# sessions start. Nothing typed by the operator becomes a setting. A layout +# is loaded into the console and tested before it is kept. The settings go to +# /run/rite-setup/settings, where rite-kiosk reads them for cage (XKB) and +# the session programs (TZ). If setup fails, the session uses US and UTC. +# This script always exits 0. + +# Ctrl-C, Ctrl-\ and Ctrl-Z do nothing: a stopped script would hold the boot. +trap '' INT QUIT TSTP + +# shellcheck disable=SC1091 +source /usr/lib/rite/setup-values + +COUNTRIES=/usr/share/zoneinfo/iso3166.tab +TITLE="Rite Live" + +layout=us +variant= +zone=UTC + +# whiptail draws on stdout and prints the choice on stderr; the swap returns +# the choice on stdout and draws on stderr, which is the terminal. +ask() { + whiptail --title "$TITLE" "$@" 3>&1 1>&2 2>&3 +} + +message() { + ask --msgbox "$1" 10 64 +} + +save() { + if ! printf 'layout=%s\nvariant=%s\ntimezone=%s\n' "$layout" "$variant" "$zone" \ + > "$SETTINGS.new" || ! mv -f "$SETTINGS.new" "$SETTINGS"; then + rm -f "$SETTINGS.new" "$SETTINGS" + layout=us + variant= + zone=UTC + load_keymap us "" + message "Could not save the settings. The sessions will use US and UTC." + fi +} + +# Loads layout $1, variant $2 into the console keymap, which every terminal +# shares. +load_keymap() { + local args=(-layout "$1") status + [[ -n $2 ]] && args+=(-variant "$2") + ask --infobox "Loading the keyboard layout..." 7 50 + ckbcomp "${args[@]}" 2>/dev/null | loadkeys -q 2>/dev/null + status=("${PIPESTATUS[@]}") + [[ ${status[0]} -eq 0 && ${status[1]} -eq 0 ]] +} + +# ckbcomp warns and falls back to the base layout for an unknown variant. +variant_listed() { + [[ -z $2 || -n $(xkb_description "$1" "$2") ]] +} + +# Every layout of the XKB rules, then its variants. Prints "layout variant". +other_layout() { + local name rest items=() choice + while read -r name rest; do + items+=("$name" "$rest") + done < <(xkb_section layout) + choice=$(ask --notags --menu "Keyboard layout:" 22 72 14 "${items[@]}") || return 1 + + # A variant line reads "name layout: description". + items=("-" "No variant") + while read -r name rest; do + [[ ${rest%%:*} == "$choice" ]] && items+=("$name" "${rest#*: }") + done < <(xkb_section variant) + if [[ ${#items[@]} -eq 2 ]]; then + echo "$choice" + return 0 + fi + rest=$(ask --notags --menu "Variant for $(layout_name "$choice" ""):" 22 72 14 \ + "${items[@]}") || return 1 + [[ $rest == - ]] && rest="" + echo "$choice $rest" +} + +change_keyboard() { + local choice new_layout new_variant new_name current_name typed + # Widely used layouts, by their names in the XKB rules. + choice=$(ask --notags --default-item "$layout${variant:+ $variant}" \ + --menu "Keyboard layout:" 20 60 11 \ + "gb" "English (UK)" \ + "us" "English (US)" \ + "fr" "French" \ + "ch fr" "French (Switzerland)" \ + "de" "German" \ + "ch" "German (Switzerland)" \ + "it" "Italian" \ + "jp" "Japanese" \ + "br" "Portuguese (Brazil)" \ + "es" "Spanish" \ + "other" "Other layouts...") || return 0 + if [[ $choice == other ]]; then + choice=$(other_layout) || return 0 + fi + read -r new_layout new_variant <<< "$choice" + new_name=$(layout_name "$new_layout" "$new_variant") + current_name=$(layout_name "$layout" "$variant") + if ! valid_layout "$new_layout" || ! valid_variant "$new_variant" \ + || ! variant_listed "$new_layout" "$new_variant"; then + message "Layout $new_name is not supported. Keeping $current_name." + return 0 + fi + + if ! load_keymap "$new_layout" "$new_variant"; then + if load_keymap "$layout" "$variant"; then + message "Could not load $new_name. Keeping $current_name." + else + message "Could not load $new_name or restore $current_name. The keyboard may not work. Power off and restart." + fi + return 0 + fi + typed=$(ask --inputbox "Type anything to check the layout, then press Enter." \ + 8 60) || typed="" + if ask --yesno "You typed: + + $typed + +Keep this layout: $new_name?" 12 60; then + layout=$new_layout + variant=$new_variant + save + elif ! load_keymap "$layout" "$variant"; then + # The console still has the new layout: keep it, so that the settings + # and the start screen say what the keyboard does. + layout=$new_layout + variant=$new_variant + save + message "Could not restore $current_name. Keeping $new_name." + fi +} + +# UTC or a region, then a zone of that region, each shown with its country. +change_zone() { + local choice items=("UTC" "") code name comment + local -A country + while IFS= read -r name; do + items+=("$name" "") + done < <(grep -v '^#' "$ZONE_TAB" | cut -f3 | cut -d/ -f1 | sort -u) + choice=$(ask --default-item "${zone%%/*}" --menu "Choose a region:" 20 50 12 \ + "${items[@]}") || return 0 + + if [[ $choice != UTC ]]; then + while IFS=$'\t' read -r code name; do + country[$code]=$name + done < <(grep -v '^#' "$COUNTRIES") + items=() + while IFS=$'\t' read -r code _ name comment; do + [[ $name == "$choice"/* ]] || continue + items+=("$name" "${country[$code]:-$code}${comment:+, $comment}") + done < <(grep -v '^#' "$ZONE_TAB" | sort -t$'\t' -k3) + choice=$(ask --default-item "$zone" --menu "Choose a time zone:" 22 76 14 \ + "${items[@]}") || return 0 + fi + if ! valid_zone "$choice"; then + message "Time zone $choice is not supported. Keeping $zone." + return 0 + fi + zone=$choice + save +} + +while true; do + choice=$(ask --notags --nocancel --default-item start --menu "\ +Keyboard: $(layout_name "$layout" "$variant") +Time zone: $zone +Time: $(date -u '+%Y-%m-%d %H:%M UTC'), $(TZ=$zone date '+%H:%M %Z') local + +Check the clock against a watch. +If it is wrong, power off and correct it in the firmware settings." 19 76 5 \ + "start" "Continue" \ + "keyboard" "Change the keyboard layout" \ + "zone" "Change the time zone" \ + "time" "Refresh the time" \ + "poweroff" "Power off") || choice=start + case $choice in + keyboard) change_keyboard ;; + zone) change_zone ;; + time) ;; + poweroff) + if ask --defaultno --yesno "Power off the machine?" 8 40; then + # polkit lets the rite user power off (10-rite.rules). + systemctl poweroff && exit 0 + message "Could not power off the machine. Try again." + fi + ;; + *) break ;; + esac +done +clear +exit 0 diff --git a/img/mkosi/mkosi.extra/usr/lib/rite/setup-values b/img/mkosi/mkosi.extra/usr/lib/rite/setup-values new file mode 100644 index 0000000..2bc7d1c --- /dev/null +++ b/img/mkosi/mkosi.extra/usr/lib/rite/setup-values @@ -0,0 +1,56 @@ +# shellcheck shell=bash +# /usr/lib/rite/setup-values +# The keyboard layout and time zone values: their checks and the names the +# screens show. Sourced by rite-setup, which writes them, and by rite-kiosk and +# rite-session, which read them back. + +# Used by the scripts that source this file. +# shellcheck disable=SC2034 +SETTINGS=/run/rite-setup/settings +XKB_RULES=/usr/share/X11/xkb/rules/evdev.lst +ZONE_TAB=/usr/share/zoneinfo/zone.tab + +valid_layout() { + [[ $1 =~ ^[a-z0-9_]+$ ]] +} + +# Some variants have capitals: de T3, jp OADG109A. +valid_variant() { + [[ -z $1 || $1 =~ ^[A-Za-z0-9_][A-Za-z0-9_-]*$ ]] +} + +# UTC, or a zone of zone.tab, which lists one or more zones per country. +valid_zone() { + [[ $1 == UTC ]] && return 0 + [[ $1 =~ ^[A-Za-z0-9_+-]+(/[A-Za-z0-9_+-]+)*$ ]] \ + && grep -v '^#' "$ZONE_TAB" | cut -f3 | grep -qxF -- "$1" +} + +# Prints the entries of section $1 of the XKB rules ("layout", "variant"), one +# per line: the name, then the description. A variant's description starts +# with its layout: "fr ch: French (Switzerland)". +xkb_section() { + awk -v want="$1" '/^! / { section = $2; next } NF && section == want { + sub(/^ +/, ""); print }' "$XKB_RULES" +} + +# Prints the description of layout $1 with variant $2 from the XKB rules, such +# as "French (Switzerland)", or nothing if the rules do not list them. awk reads +# the file itself: a lookup that stops early must not leave a writer on a closed +# pipe, since systemd services ignore SIGPIPE. +xkb_description() { + awk -v layout="$1" -v variant="$2" ' + /^! / { section = $2; next } + variant == "" && section == "layout" && $1 == layout { + sub(/^ *[^ ]+ +/, ""); print; exit } + variant != "" && section == "variant" && $1 == variant && $2 == layout ":" { + sub(/^ *[^ ]+ +[^ ]+ +/, ""); print; exit }' "$XKB_RULES" +} + +# Prints the name of layout $1 with variant $2, or the codes if the XKB rules +# do not list it. +layout_name() { + local name + name=$(xkb_description "$1" "$2") + echo "${name:-$1${2:+ ($2)}}" +} diff --git a/img/mkosi/mkosi.extra/usr/lib/systemd/system-preset/10-rite.preset b/img/mkosi/mkosi.extra/usr/lib/systemd/system-preset/10-rite.preset index 080a2cc..894880e 100644 --- a/img/mkosi/mkosi.extra/usr/lib/systemd/system-preset/10-rite.preset +++ b/img/mkosi/mkosi.extra/usr/lib/systemd/system-preset/10-rite.preset @@ -1,6 +1,7 @@ # Units of the Live system. mkosi applies the presets at the end of the # build (systemctl preset-all); Debian's default enables every other unit, # systemd-networkd and systemd-resolved among them. +enable rite-setup.service enable rite-session.service enable rite-shell.service enable rite-ceremony-load.service diff --git a/img/mkosi/mkosi.extra/usr/lib/systemd/system/rite-setup.service b/img/mkosi/mkosi.extra/usr/lib/systemd/system/rite-setup.service new file mode 100644 index 0000000..9726fae --- /dev/null +++ b/img/mkosi/mkosi.extra/usr/lib/systemd/system/rite-setup.service @@ -0,0 +1,40 @@ +# The keyboard layout and time zone, chosen on tty1 before the sessions start +# (rite-setup), as the rite user. The sessions start whether setup succeeds or +# not. On failure, the stop command removes the settings and restores the US +# console keymap. +[Unit] +Description=Rite keyboard layout and time zone +After=systemd-user-sessions.service +Before=rite-session.service rite-shell.service +ConditionPathExists=/dev/tty1 + +[Service] +Type=oneshot +# Once per boot. +RemainAfterExit=yes +# Kernel messages would print over the menus. +ExecStartPre=+/usr/bin/dmesg --console-off +ExecStart=/usr/lib/rite/rite-setup +# Restore kernel messages and protect settings before the sessions start. +# On failure, the stop command also restores the US keymap. +ExecStartPost=+/usr/bin/dmesg --console-on +ExecStartPost=+/usr/bin/chown -R root:root /run/rite-setup +ExecStopPost=+/usr/bin/dmesg --console-on +ExecStopPost=+/usr/bin/chown -R root:root /run/rite-setup +ExecStopPost=+/bin/sh -c 'test "$SERVICE_RESULT" = success || { rm -f /run/rite-setup/settings; ckbcomp us | loadkeys -q; }' +User=rite +# Loading the console keymap takes CAP_SYS_TTY_CONFIG, even on the process's +# own terminal. +AmbientCapabilities=CAP_SYS_TTY_CONFIG +RuntimeDirectory=rite-setup +RuntimeDirectoryPreserve=yes +TimeoutStartSec=infinity +TTYPath=/dev/tty1 +TTYReset=yes +TTYVHangup=yes +StandardInput=tty +StandardOutput=tty +StandardError=tty + +[Install] +WantedBy=multi-user.target diff --git a/img/mkosi/mkosi.postinst.chroot b/img/mkosi/mkosi.postinst.chroot index ee892a7..5fed228 100755 --- a/img/mkosi/mkosi.postinst.chroot +++ b/img/mkosi/mkosi.postinst.chroot @@ -31,6 +31,11 @@ systemctl mask getty@tty1.service getty@tty2.service # for them at the console. systemctl mask systemd-firstboot.service +# rite-setup loads the console keymap the operator chooses. Mask +# console-setup.service so it cannot replace that keymap after setup, and +# keyboard-setup.service, whose US keymap setup replaces anyway. +systemctl mask keyboard-setup.service console-setup.service + # systemd would mount the EFI system partition read-write on first access to # /boot or /efi. The system needs nothing there after boot, and a mount can # change the partition's bytes, which the medium hash of build-img covers.